agentpeek 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
agentpeek/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("agentpeek")
5
+ except PackageNotFoundError:
6
+ __version__ = "0.0.0+unknown"
7
+
8
+ __all__ = ["__version__"]
agentpeek/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from agentpeek.cli import main
2
+
3
+ raise SystemExit(main())
agentpeek/cli.py ADDED
@@ -0,0 +1,42 @@
1
+ import argparse
2
+ import importlib.metadata
3
+ from pathlib import Path
4
+
5
+ from agentpeek.logging_setup import configure_logging
6
+
7
+
8
+ def main(argv: list[str] | None = None) -> int:
9
+ parser = argparse.ArgumentParser(
10
+ prog="agentpeek",
11
+ description="TUI inspector for agent CLI configuration directories.",
12
+ )
13
+ parser.add_argument(
14
+ "--version",
15
+ action="version",
16
+ version=f"agentpeek {importlib.metadata.version('agentpeek')}",
17
+ )
18
+ parser.add_argument(
19
+ "--root",
20
+ type=Path,
21
+ default=None,
22
+ help="Config root to scan. Default: ~/.claude.",
23
+ )
24
+ parser.add_argument(
25
+ "--source",
26
+ default=None,
27
+ help="Force a source by name. Default: auto-detect.",
28
+ )
29
+ parser.add_argument(
30
+ "--log-level",
31
+ default="WARNING",
32
+ choices=["DEBUG", "INFO", "WARNING", "ERROR"],
33
+ )
34
+ args = parser.parse_args(argv)
35
+ configure_logging(args.log_level)
36
+
37
+ # Defer Textual import so --version and --help are fast and don't drag the
38
+ # TUI into module-load time when only the CLI surface is exercised.
39
+ from agentpeek.tui.app import AgentViewApp # noqa: PLC0415
40
+
41
+ AgentViewApp(scan_root=args.root, source_name=args.source).run()
42
+ return 0
agentpeek/health.py ADDED
@@ -0,0 +1,136 @@
1
+ from collections import defaultdict
2
+
3
+ from agentpeek.models import ScanReport, ScanResult, ScanWarning
4
+
5
+
6
+ def run_health_checks(result: ScanResult) -> list[ScanWarning]:
7
+ issues: list[ScanWarning] = []
8
+ issues.extend(_check_conflicting_keybindings(result))
9
+ issues.extend(_check_orphan_hooks(result))
10
+ issues.extend(_check_plugin_state(result))
11
+ return issues
12
+
13
+
14
+ def _check_conflicting_keybindings(result: ScanResult) -> list[ScanWarning]:
15
+ if result.keybindings is None:
16
+ return []
17
+ seen: defaultdict[tuple[str, str], list[str]] = defaultdict(list)
18
+ for entry in result.keybindings.entries:
19
+ seen[(entry.context, entry.key)].append(entry.action)
20
+ issues: list[ScanWarning] = []
21
+ for (context, key), actions in sorted(seen.items()):
22
+ unique = sorted(set(actions))
23
+ if len(unique) > 1:
24
+ issues.append(
25
+ ScanWarning(
26
+ path=result.keybindings.path,
27
+ category="conflicting_binding",
28
+ reason=(
29
+ f"context {context!r} key {key!r} maps to multiple "
30
+ f"actions: {unique}"
31
+ ),
32
+ )
33
+ )
34
+ return issues
35
+
36
+
37
+ def _check_orphan_hooks(result: ScanResult) -> list[ScanWarning]:
38
+ hooks_dir = result.root / "hooks"
39
+ if not hooks_dir.is_dir():
40
+ return []
41
+ referenced = {
42
+ h.referenced_script.resolve()
43
+ for h in result.hooks
44
+ if h.referenced_script is not None
45
+ }
46
+ issues: list[ScanWarning] = []
47
+ for f in sorted(hooks_dir.iterdir()):
48
+ if f.is_file() and f.resolve() not in referenced:
49
+ issues.append(
50
+ ScanWarning(
51
+ path=f,
52
+ category="orphan_hook",
53
+ reason=(
54
+ f"script {f.name} is not referenced from any settings hook"
55
+ ),
56
+ )
57
+ )
58
+ return issues
59
+
60
+
61
+ def _check_plugin_state(result: ScanResult) -> list[ScanWarning]:
62
+ issues: list[ScanWarning] = []
63
+ for plugin in result.plugins:
64
+ if plugin.enabled and not plugin.installations:
65
+ issues.append(
66
+ ScanWarning(
67
+ path=None,
68
+ category="plugin_state",
69
+ reason=(
70
+ f"plugin {plugin.qualified_id} is enabled but has "
71
+ "no installations on disk"
72
+ ),
73
+ )
74
+ )
75
+ return issues
76
+
77
+
78
+ def run_cross_scope_checks(report: ScanReport) -> list[ScanWarning]:
79
+ """Cross-scope diagnostics: comparing user-level and project-level scans
80
+ for overlaps, overrides, and layered configuration."""
81
+ if report.user is None or report.project is None:
82
+ return []
83
+ issues: list[ScanWarning] = []
84
+ issues.extend(_check_scope_override_command(report))
85
+ issues.extend(_check_scope_override_plugin(report))
86
+ issues.extend(_check_scope_layered_memory(report))
87
+ return issues
88
+
89
+
90
+ def _check_scope_override_command(report: ScanReport) -> list[ScanWarning]:
91
+ assert report.user is not None and report.project is not None
92
+ user_names = {c.name for c in report.user.commands}
93
+ project_names = {c.name for c in report.project.commands}
94
+ return [
95
+ ScanWarning(
96
+ path=None,
97
+ category="scope_override_command",
98
+ reason=(
99
+ f"slash command /{name} exists in both user and project scope; "
100
+ "project version takes precedence"
101
+ ),
102
+ )
103
+ for name in sorted(user_names & project_names)
104
+ ]
105
+
106
+
107
+ def _check_scope_override_plugin(report: ScanReport) -> list[ScanWarning]:
108
+ assert report.user is not None and report.project is not None
109
+ user_ids = {p.qualified_id for p in report.user.plugins}
110
+ project_ids = {p.qualified_id for p in report.project.plugins}
111
+ return [
112
+ ScanWarning(
113
+ path=None,
114
+ category="scope_override_plugin",
115
+ reason=(f"plugin {qid} has installations in both user and project scope"),
116
+ )
117
+ for qid in sorted(user_ids & project_ids)
118
+ ]
119
+
120
+
121
+ def _check_scope_layered_memory(report: ScanReport) -> list[ScanWarning]:
122
+ assert report.user is not None and report.project is not None
123
+ user_claude = any(m.kind == "claude_md" for m in report.user.memory)
124
+ project_claude = any(m.kind == "claude_md" for m in report.project.memory)
125
+ if not (user_claude and project_claude):
126
+ return []
127
+ return [
128
+ ScanWarning(
129
+ path=None,
130
+ category="scope_layered_memory",
131
+ reason=(
132
+ "CLAUDE.md exists at both user and project scope; "
133
+ "project memory layers on top of user memory"
134
+ ),
135
+ )
136
+ ]
@@ -0,0 +1,8 @@
1
+ import logging
2
+
3
+
4
+ def configure_logging(level: str) -> None:
5
+ logging.basicConfig(
6
+ level=getattr(logging, level),
7
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
8
+ )
agentpeek/models.py ADDED
@@ -0,0 +1,196 @@
1
+ from collections.abc import Mapping
2
+ from dataclasses import dataclass
3
+ from pathlib import Path
4
+ from typing import Literal
5
+
6
+
7
+ @dataclass(frozen=True, slots=True)
8
+ class ScanWarning:
9
+ path: Path | None
10
+ category: str
11
+ reason: str
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class SettingsBundle:
16
+ user_settings_path: Path | None
17
+ local_settings_path: Path | None
18
+ model: str | None
19
+ theme: str | None
20
+ editor_mode: str | None
21
+ effort_level: str | None
22
+ env: Mapping[str, str]
23
+ permissions_allow: tuple[str, ...]
24
+ permissions_deny: tuple[str, ...]
25
+ permissions_ask: tuple[str, ...]
26
+ enabled_plugins: tuple[str, ...]
27
+ hooks_raw: Mapping[str, tuple[Mapping[str, object], ...]]
28
+ hooks_dir_files: int
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class HookSpec:
33
+ event: str
34
+ matcher: str | None
35
+ type: str
36
+ command: str
37
+ timeout: int | None
38
+ referenced_script: Path | None
39
+ script_exists: bool
40
+ # `source_plugin` is the qualified id of a plugin (`name@market`) when
41
+ # this hook was contributed by an installed plugin. None for hooks
42
+ # declared in a user or project settings file.
43
+ source_plugin: str | None = None
44
+
45
+
46
+ @dataclass(frozen=True, slots=True)
47
+ class SlashCommand:
48
+ path: Path
49
+ name: str
50
+ description: str | None
51
+ argument_hint: str | None
52
+ allowed_tools: tuple[str, ...]
53
+ body: str
54
+ source_plugin: str | None = None
55
+
56
+
57
+ @dataclass(frozen=True, slots=True)
58
+ class PluginInstallation:
59
+ scope: str
60
+ install_path: Path
61
+ version: str
62
+ installed_at: str
63
+ last_updated: str
64
+ git_commit_sha: str | None
65
+ project_path: Path | None
66
+
67
+
68
+ @dataclass(frozen=True, slots=True)
69
+ class PluginManifest:
70
+ description: str | None
71
+ version: str | None
72
+ author_name: str | None
73
+ author_email: str | None
74
+ homepage: str | None
75
+ license: str | None
76
+ keywords: tuple[str, ...] = ()
77
+
78
+
79
+ @dataclass(frozen=True, slots=True)
80
+ class PluginSkill:
81
+ path: Path
82
+ name: str
83
+ description: str | None
84
+ body: str
85
+ source_plugin: str | None = None
86
+
87
+
88
+ @dataclass(frozen=True, slots=True)
89
+ class PluginAgent:
90
+ path: Path
91
+ name: str
92
+ description: str | None
93
+ body: str
94
+ source_plugin: str | None = None
95
+
96
+
97
+ @dataclass(frozen=True, slots=True)
98
+ class Plugin:
99
+ id: str
100
+ marketplace: str
101
+ qualified_id: str
102
+ enabled: bool
103
+ installations: tuple[PluginInstallation, ...]
104
+ # Contents enumerated from the first installation's `install_path`.
105
+ # All-optional / empty-default so existing test constructors and
106
+ # future non-Claude sources (e.g. a CodexSource) can build Plugin
107
+ # without populating these.
108
+ manifest: PluginManifest | None = None
109
+ skills: tuple[PluginSkill, ...] = ()
110
+ agents: tuple[PluginAgent, ...] = ()
111
+ commands: tuple[SlashCommand, ...] = ()
112
+ hooks: tuple[HookSpec, ...] = ()
113
+ mcps: tuple["MCPServer", ...] = ()
114
+
115
+
116
+ MemoryKind = Literal["claude_md", "memory_index", "memory_entry"]
117
+
118
+
119
+ @dataclass(frozen=True, slots=True)
120
+ class MemoryFile:
121
+ path: Path
122
+ body: str
123
+ has_frontmatter: bool
124
+ kind: MemoryKind
125
+ project_label: str | None
126
+
127
+
128
+ @dataclass(frozen=True, slots=True)
129
+ class KeybindingEntry:
130
+ context: str
131
+ key: str
132
+ action: str
133
+
134
+
135
+ @dataclass(frozen=True, slots=True)
136
+ class KeybindingsBundle:
137
+ path: Path | None
138
+ entries: tuple[KeybindingEntry, ...]
139
+
140
+
141
+ @dataclass(frozen=True, slots=True)
142
+ class MCPServer:
143
+ name: str
144
+ source_path: Path
145
+ command: str | None
146
+ args: tuple[str, ...]
147
+ env: Mapping[str, str]
148
+ source_plugin: str | None = None
149
+
150
+
151
+ @dataclass(frozen=True, slots=True)
152
+ class ScanResult:
153
+ source: str
154
+ root: Path
155
+ settings: SettingsBundle | None
156
+ hooks: tuple[HookSpec, ...]
157
+ commands: tuple[SlashCommand, ...]
158
+ plugins: tuple[Plugin, ...]
159
+ memory: tuple[MemoryFile, ...]
160
+ keybindings: KeybindingsBundle | None
161
+ mcp: tuple[MCPServer, ...]
162
+ warnings: tuple[ScanWarning, ...]
163
+
164
+ @classmethod
165
+ def empty(
166
+ cls, *, root: Path | None = None, reason: str | None = None
167
+ ) -> "ScanResult":
168
+ ws: tuple[ScanWarning, ...] = (
169
+ (ScanWarning(path=None, category="source", reason=reason),)
170
+ if reason
171
+ else ()
172
+ )
173
+ return cls(
174
+ source="",
175
+ root=root or Path("/"),
176
+ settings=None,
177
+ hooks=(),
178
+ commands=(),
179
+ plugins=(),
180
+ memory=(),
181
+ keybindings=None,
182
+ mcp=(),
183
+ warnings=ws,
184
+ )
185
+
186
+
187
+ @dataclass(frozen=True, slots=True)
188
+ class ScanReport:
189
+ user: ScanResult | None
190
+ project: ScanResult | None
191
+ project_root: Path | None
192
+ cross_scope_warnings: tuple[ScanWarning, ...] = ()
193
+
194
+ @property
195
+ def primary(self) -> ScanResult | None:
196
+ return self.project or self.user
@@ -0,0 +1,4 @@
1
+ from agentpeek.parsers.frontmatter_parser import FrontmatterFile, load_frontmatter
2
+ from agentpeek.parsers.json_parser import load_json
3
+
4
+ __all__ = ["FrontmatterFile", "load_frontmatter", "load_json"]
@@ -0,0 +1,51 @@
1
+ from collections.abc import Mapping
2
+ from dataclasses import dataclass
3
+ from pathlib import Path
4
+ from typing import cast
5
+
6
+ import frontmatter
7
+ import yaml
8
+
9
+ from agentpeek.models import ScanWarning
10
+
11
+
12
+ @dataclass(frozen=True, slots=True)
13
+ class FrontmatterFile:
14
+ metadata: Mapping[str, object]
15
+ body: str
16
+
17
+
18
+ def load_frontmatter(
19
+ path: Path, *, category: str
20
+ ) -> tuple[FrontmatterFile | None, ScanWarning | None]:
21
+ try:
22
+ text = path.read_text(encoding="utf-8")
23
+ except FileNotFoundError:
24
+ return None, ScanWarning(
25
+ path=path, category=category, reason=f"file not found: {path}"
26
+ )
27
+ except UnicodeDecodeError as e:
28
+ return None, ScanWarning(
29
+ path=path,
30
+ category=category,
31
+ reason=f"could not decode utf-8: {e.reason}",
32
+ )
33
+ except OSError as e:
34
+ return None, ScanWarning(
35
+ path=path, category=category, reason=f"could not read: {e.strerror}"
36
+ )
37
+
38
+ try:
39
+ post = frontmatter.loads(text)
40
+ except yaml.YAMLError as e:
41
+ return None, ScanWarning(
42
+ path=path, category=category, reason=f"invalid frontmatter YAML: {e}"
43
+ )
44
+ except Exception as e:
45
+ return None, ScanWarning(
46
+ path=path, category=category, reason=f"unexpected error: {e!r}"
47
+ )
48
+
49
+ metadata = cast("Mapping[str, object]", dict(post.metadata)) # type: ignore[reportUnknownArgumentType]
50
+ body = cast("str", post.content) # type: ignore[reportUnknownMemberType]
51
+ return FrontmatterFile(metadata=metadata, body=body), None
@@ -0,0 +1,33 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ from agentpeek.models import ScanWarning
5
+
6
+
7
+ def load_json(path: Path, *, category: str) -> tuple[object | None, ScanWarning | None]:
8
+ try:
9
+ return json.loads(path.read_text(encoding="utf-8")), None
10
+ except FileNotFoundError:
11
+ return None, ScanWarning(
12
+ path=path, category=category, reason=f"file not found: {path}"
13
+ )
14
+ except json.JSONDecodeError as e:
15
+ return None, ScanWarning(
16
+ path=path,
17
+ category=category,
18
+ reason=f"invalid JSON at line {e.lineno} col {e.colno}: {e.msg}",
19
+ )
20
+ except UnicodeDecodeError as e:
21
+ return None, ScanWarning(
22
+ path=path,
23
+ category=category,
24
+ reason=f"could not decode utf-8: {e.reason}",
25
+ )
26
+ except OSError as e:
27
+ return None, ScanWarning(
28
+ path=path, category=category, reason=f"could not read: {e.strerror}"
29
+ )
30
+ except Exception as e:
31
+ return None, ScanWarning(
32
+ path=path, category=category, reason=f"unexpected error: {e!r}"
33
+ )