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 +8 -0
- agentpeek/__main__.py +3 -0
- agentpeek/cli.py +42 -0
- agentpeek/health.py +136 -0
- agentpeek/logging_setup.py +8 -0
- agentpeek/models.py +196 -0
- agentpeek/parsers/__init__.py +4 -0
- agentpeek/parsers/frontmatter_parser.py +51 -0
- agentpeek/parsers/json_parser.py +33 -0
- agentpeek/parsers/plugin_contents.py +375 -0
- agentpeek/py.typed +0 -0
- agentpeek/scanner.py +182 -0
- agentpeek/sources/__init__.py +4 -0
- agentpeek/sources/base.py +15 -0
- agentpeek/sources/local.py +529 -0
- agentpeek/tui/__init__.py +0 -0
- agentpeek/tui/app.py +41 -0
- agentpeek/tui/render.py +1060 -0
- agentpeek/tui/screens/__init__.py +0 -0
- agentpeek/tui/screens/help.py +40 -0
- agentpeek/tui/screens/main.py +280 -0
- agentpeek/tui/screens/skill_detail.py +48 -0
- agentpeek/tui/styles/app.tcss +237 -0
- agentpeek/tui/widgets/__init__.py +0 -0
- agentpeek-0.9.0.dist-info/METADATA +51 -0
- agentpeek-0.9.0.dist-info/RECORD +29 -0
- agentpeek-0.9.0.dist-info/WHEEL +4 -0
- agentpeek-0.9.0.dist-info/entry_points.txt +2 -0
- agentpeek-0.9.0.dist-info/licenses/LICENSE +674 -0
agentpeek/__init__.py
ADDED
agentpeek/__main__.py
ADDED
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
|
+
]
|
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,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
|
+
)
|