agt-env 0.0.3__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.
- agt_env/__init__.py +3 -0
- agt_env/__main__.py +5 -0
- agt_env/adapters/__init__.py +16 -0
- agt_env/adapters/antigravity.py +306 -0
- agt_env/adapters/base.py +266 -0
- agt_env/adapters/claude_code.py +720 -0
- agt_env/adapters/codex.py +539 -0
- agt_env/adapters/opencode.py +598 -0
- agt_env/adapters/pi.py +537 -0
- agt_env/cli.py +206 -0
- agt_env/filters.py +76 -0
- agt_env/frontmatter.py +51 -0
- agt_env/hooks.py +97 -0
- agt_env/jsonc.py +19 -0
- agt_env/mcp.py +97 -0
- agt_env/model.py +85 -0
- agt_env/render_plain.py +156 -0
- agt_env/render_pretty.py +248 -0
- agt_env/skills.py +208 -0
- agt_env-0.0.3.dist-info/METADATA +45 -0
- agt_env-0.0.3.dist-info/RECORD +24 -0
- agt_env-0.0.3.dist-info/WHEEL +4 -0
- agt_env-0.0.3.dist-info/entry_points.txt +2 -0
- agt_env-0.0.3.dist-info/licenses/LICENSE +21 -0
agt_env/__init__.py
ADDED
agt_env/__main__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""The adapter registry, in rank order."""
|
|
2
|
+
|
|
3
|
+
from agt_env.adapters.antigravity import Antigravity
|
|
4
|
+
from agt_env.adapters.base import Adapter
|
|
5
|
+
from agt_env.adapters.claude_code import ClaudeCode
|
|
6
|
+
from agt_env.adapters.codex import Codex
|
|
7
|
+
from agt_env.adapters.opencode import OpenCode
|
|
8
|
+
from agt_env.adapters.pi import Pi
|
|
9
|
+
|
|
10
|
+
ADAPTERS: tuple[Adapter, ...] = (
|
|
11
|
+
ClaudeCode(),
|
|
12
|
+
Codex(),
|
|
13
|
+
OpenCode(),
|
|
14
|
+
Pi(),
|
|
15
|
+
Antigravity(),
|
|
16
|
+
)
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""Antigravity CLI adapter. The binary is `agy`.
|
|
2
|
+
|
|
3
|
+
Discovery rules, official references, and undocumented findings are in
|
|
4
|
+
docs/agents/antigravity.md. Update that file when these rules change. tests/test_antigravity_oracle.py
|
|
5
|
+
compares results with the real agent.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from agt_env.adapters.base import (
|
|
12
|
+
Context,
|
|
13
|
+
definition_item,
|
|
14
|
+
first_existing,
|
|
15
|
+
list_entries,
|
|
16
|
+
listed_paths,
|
|
17
|
+
load_once,
|
|
18
|
+
loads_skills,
|
|
19
|
+
mark_bundled,
|
|
20
|
+
mcp_item,
|
|
21
|
+
plugin_item,
|
|
22
|
+
read_json,
|
|
23
|
+
skill_item,
|
|
24
|
+
)
|
|
25
|
+
from agt_env.hooks import handler_content, handler_details, handlers
|
|
26
|
+
from agt_env.mcp import remote_details, stdio_details
|
|
27
|
+
from agt_env.model import AgentReport, Finding, Item, Kind, Scope, Status
|
|
28
|
+
from agt_env.skills import FoundSkill, read_markdown, scan_skills
|
|
29
|
+
|
|
30
|
+
_UNNAMED_SKILL = "SKILL"
|
|
31
|
+
_NEEDS_NAME_AND_DESCRIPTION = "needs a name and a description, which Antigravity CLI requires"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Antigravity:
|
|
35
|
+
id = "antigravity"
|
|
36
|
+
|
|
37
|
+
def scan(self, context: Context) -> AgentReport:
|
|
38
|
+
gemini_dir = context.home / ".gemini"
|
|
39
|
+
cli_dir = gemini_dir / "antigravity-cli"
|
|
40
|
+
shared_dir = gemini_dir / "config"
|
|
41
|
+
workspace_dir = context.cwd / ".agents"
|
|
42
|
+
user_mcp = shared_dir / "mcp_config.json"
|
|
43
|
+
project_mcp = workspace_dir / "mcp_config.json"
|
|
44
|
+
|
|
45
|
+
user_skill_dirs = [cli_dir / "skills", shared_dir / "skills", gemini_dir / "skills"]
|
|
46
|
+
report = AgentReport(
|
|
47
|
+
agent=self.id,
|
|
48
|
+
installed=context.on_path("agy"),
|
|
49
|
+
config_path=first_existing(
|
|
50
|
+
cli_dir,
|
|
51
|
+
user_mcp,
|
|
52
|
+
project_mcp,
|
|
53
|
+
*user_skill_dirs[1:],
|
|
54
|
+
shared_dir / "plugins",
|
|
55
|
+
shared_dir / "agents",
|
|
56
|
+
shared_dir / "hooks.json",
|
|
57
|
+
gemini_dir / "agents",
|
|
58
|
+
workspace_dir / "agents",
|
|
59
|
+
workspace_dir / "hooks.json",
|
|
60
|
+
),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
settings, settings_ok = read_json(cli_dir / "settings.json", report.warnings)
|
|
64
|
+
project_status = _workspace_status(settings, settings_ok, context.cwd)
|
|
65
|
+
|
|
66
|
+
skill_roots = [
|
|
67
|
+
*[(Scope.USER, root, Status.ENABLED) for root in user_skill_dirs],
|
|
68
|
+
(Scope.PROJECT, workspace_dir / "skills", project_status),
|
|
69
|
+
(Scope.PROJECT, context.cwd / ".agent" / "skills", project_status),
|
|
70
|
+
]
|
|
71
|
+
for scope, root, default in skill_roots:
|
|
72
|
+
found, warnings = scan_skills(root, include_hidden=True)
|
|
73
|
+
report.warnings.extend(warnings)
|
|
74
|
+
for skill in found:
|
|
75
|
+
report.items.append(self._skill(skill, scope, default))
|
|
76
|
+
report.items = load_once(report.items)
|
|
77
|
+
|
|
78
|
+
for path, scope, default in (
|
|
79
|
+
(user_mcp, Scope.USER, Status.ENABLED),
|
|
80
|
+
(project_mcp, Scope.PROJECT, project_status),
|
|
81
|
+
):
|
|
82
|
+
report.items.extend(self._servers(path, scope, default, report))
|
|
83
|
+
|
|
84
|
+
state, state_ok = read_json(shared_dir / "config.json", report.warnings)
|
|
85
|
+
states = state.get("plugins")
|
|
86
|
+
states = states if isinstance(states, dict) else {}
|
|
87
|
+
for root, scope, default in (
|
|
88
|
+
(shared_dir / "plugins", Scope.USER, Status.ENABLED),
|
|
89
|
+
(workspace_dir / "plugins", Scope.PROJECT, project_status),
|
|
90
|
+
):
|
|
91
|
+
plugins = self._plugins(root, scope, states, state_ok, report.warnings, default)
|
|
92
|
+
report.items.extend(plugins)
|
|
93
|
+
for plugin in plugins:
|
|
94
|
+
if loads_skills(plugin):
|
|
95
|
+
report.items.extend(self._plugin_extras(plugin, report.warnings))
|
|
96
|
+
report.items.extend(
|
|
97
|
+
load_once(
|
|
98
|
+
[
|
|
99
|
+
skill
|
|
100
|
+
for plugin in plugins
|
|
101
|
+
if loads_skills(plugin)
|
|
102
|
+
for skill in self._plugin_skills(plugin, report.warnings)
|
|
103
|
+
]
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
for root, scope, default in (
|
|
108
|
+
(cli_dir / "agents", Scope.USER, Status.ENABLED),
|
|
109
|
+
(shared_dir / "agents", Scope.USER, Status.ENABLED),
|
|
110
|
+
(gemini_dir / "agents", Scope.USER, Status.ENABLED),
|
|
111
|
+
(workspace_dir / "agents", Scope.PROJECT, project_status),
|
|
112
|
+
(context.cwd / ".agent" / "agents", Scope.PROJECT, project_status),
|
|
113
|
+
):
|
|
114
|
+
report.items.extend(self._agents(root, scope, default, report.warnings))
|
|
115
|
+
for path, scope, default in (
|
|
116
|
+
(shared_dir / "hooks.json", Scope.USER, Status.ENABLED),
|
|
117
|
+
(workspace_dir / "hooks.json", Scope.PROJECT, project_status),
|
|
118
|
+
):
|
|
119
|
+
report.items.extend(self._hooks(path, scope, default, report.warnings))
|
|
120
|
+
return report
|
|
121
|
+
|
|
122
|
+
def _agents(self, root: Path, scope: Scope, default: Status, warnings: list[str]) -> list[Item]:
|
|
123
|
+
"""An agent is `<name>.md` or `<name>/agent.md`, one level down."""
|
|
124
|
+
items = []
|
|
125
|
+
for path in _hidden_included_entries(root, warnings):
|
|
126
|
+
file = path / "agent.md" if os.path.isdir(path) else path
|
|
127
|
+
if file.suffix != ".md" or not os.path.lexists(file):
|
|
128
|
+
continue
|
|
129
|
+
found = read_markdown(file, warnings)
|
|
130
|
+
fields = found.fields or {}
|
|
131
|
+
name, description = fields.get("name"), fields.get("description")
|
|
132
|
+
items.append(
|
|
133
|
+
definition_item(
|
|
134
|
+
self.id,
|
|
135
|
+
found,
|
|
136
|
+
Kind.SUBAGENT,
|
|
137
|
+
"agent",
|
|
138
|
+
scope,
|
|
139
|
+
default if name and description else Status.BROKEN,
|
|
140
|
+
name=name or path.stem,
|
|
141
|
+
description=description or "",
|
|
142
|
+
problem=_NEEDS_NAME_AND_DESCRIPTION,
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
return items
|
|
146
|
+
|
|
147
|
+
def _hooks(self, path: Path, scope: Scope, default: Status, warnings: list[str]) -> list[Item]:
|
|
148
|
+
"""The top-level keys are hook names, each with its own events."""
|
|
149
|
+
data, ok = read_json(path, warnings)
|
|
150
|
+
items = []
|
|
151
|
+
for name, entry in data.items():
|
|
152
|
+
fields = entry if isinstance(entry, dict) else {}
|
|
153
|
+
events = {key: value for key, value in fields.items() if key != "enabled"}
|
|
154
|
+
found = list(handlers(events))
|
|
155
|
+
summaries = [handler_details(handler.entry) for handler in found]
|
|
156
|
+
valid = isinstance(entry, dict) and bool(summaries) and all(summaries)
|
|
157
|
+
status = default if ok else Status.UNKNOWN
|
|
158
|
+
if fields.get("enabled") is False:
|
|
159
|
+
status = Status.DISABLED
|
|
160
|
+
details: dict[str, object] = {"event": list(events)}
|
|
161
|
+
if valid:
|
|
162
|
+
details["command"] = [s["command"] for s in summaries if s.get("command")]
|
|
163
|
+
items.append(
|
|
164
|
+
Item(
|
|
165
|
+
name=name,
|
|
166
|
+
kind=Kind.HOOK,
|
|
167
|
+
native_kind="hook",
|
|
168
|
+
agent=self.id,
|
|
169
|
+
scope=scope,
|
|
170
|
+
status=status if valid else Status.BROKEN,
|
|
171
|
+
description="; ".join(
|
|
172
|
+
content for handler in found if (content := handler_content(handler.entry))
|
|
173
|
+
),
|
|
174
|
+
source_path=path,
|
|
175
|
+
resolved_path=Path(os.path.realpath(path)),
|
|
176
|
+
details=details,
|
|
177
|
+
findings=[] if valid else [Finding("invalid hook entry")],
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
return items
|
|
181
|
+
|
|
182
|
+
def _plugin_extras(self, plugin: Item, warnings: list[str]) -> list[Item]:
|
|
183
|
+
items = self._agents(plugin.source_path / "agents", plugin.scope, plugin.status, warnings)
|
|
184
|
+
items += self._hooks(
|
|
185
|
+
plugin.source_path / "hooks.json", plugin.scope, plugin.status, warnings
|
|
186
|
+
)
|
|
187
|
+
for item in items:
|
|
188
|
+
mark_bundled(
|
|
189
|
+
item,
|
|
190
|
+
plugin,
|
|
191
|
+
item.name,
|
|
192
|
+
"plugin agent" if item.kind is Kind.SUBAGENT else "plugin hook",
|
|
193
|
+
)
|
|
194
|
+
return items
|
|
195
|
+
|
|
196
|
+
def _plugin_skills(self, plugin: Item, warnings: list[str]) -> list[Item]:
|
|
197
|
+
manifest, _ = read_json(_plugin_manifest(plugin.source_path), [])
|
|
198
|
+
items = []
|
|
199
|
+
# A manifest's paths add to the default directory.
|
|
200
|
+
for path in ("skills", *listed_paths(manifest.get("skills"))):
|
|
201
|
+
found, scan_warnings = scan_skills(plugin.source_path / path, include_hidden=True)
|
|
202
|
+
warnings.extend(scan_warnings)
|
|
203
|
+
for skill in found:
|
|
204
|
+
item = self._skill(skill, plugin.scope, plugin.status)
|
|
205
|
+
items.append(
|
|
206
|
+
mark_bundled(
|
|
207
|
+
item, plugin, f"{plugin.name}:{item.name}", prefix=f"{plugin.name}:"
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
return items
|
|
211
|
+
|
|
212
|
+
def _skill(self, skill: FoundSkill, scope: Scope, default: Status) -> Item:
|
|
213
|
+
status = default if not skill.broken and skill.fields is not None else Status.BROKEN
|
|
214
|
+
if skill.fields is not None:
|
|
215
|
+
skill.fields["name"] = skill.fields.get("name") or _UNNAMED_SKILL
|
|
216
|
+
return skill_item(
|
|
217
|
+
self.id, skill, scope, status, "has no frontmatter, which Antigravity CLI requires"
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
def _servers(
|
|
221
|
+
self, path: Path, scope: Scope, default: Status, report: AgentReport
|
|
222
|
+
) -> list[Item]:
|
|
223
|
+
data, ok = read_json(path, report.warnings, blank_ok=True)
|
|
224
|
+
servers = data.get("mcpServers")
|
|
225
|
+
items = []
|
|
226
|
+
for name, entry in servers.items() if isinstance(servers, dict) else ():
|
|
227
|
+
status = default if ok else Status.UNKNOWN
|
|
228
|
+
if isinstance(entry, dict) and entry.get("disabled") is True:
|
|
229
|
+
status = Status.DISABLED
|
|
230
|
+
items.append(mcp_item(self.id, name, _server_details(entry), scope, status, path))
|
|
231
|
+
return items
|
|
232
|
+
|
|
233
|
+
def _plugins(
|
|
234
|
+
self,
|
|
235
|
+
root: Path,
|
|
236
|
+
scope: Scope,
|
|
237
|
+
states: dict,
|
|
238
|
+
state_ok: bool,
|
|
239
|
+
warnings: list[str],
|
|
240
|
+
default: Status,
|
|
241
|
+
) -> list[Item]:
|
|
242
|
+
items = []
|
|
243
|
+
for path in list_entries(root, warnings):
|
|
244
|
+
manifest_path = _plugin_manifest(path)
|
|
245
|
+
if not os.path.exists(manifest_path):
|
|
246
|
+
continue
|
|
247
|
+
manifest, _ = read_json(manifest_path, [])
|
|
248
|
+
description = manifest.get("description")
|
|
249
|
+
items.append(
|
|
250
|
+
plugin_item(
|
|
251
|
+
self.id,
|
|
252
|
+
path.name,
|
|
253
|
+
"plugin",
|
|
254
|
+
scope,
|
|
255
|
+
_plugin_status(path.name, states, state_ok, default),
|
|
256
|
+
path,
|
|
257
|
+
description if isinstance(description, str) else "",
|
|
258
|
+
)
|
|
259
|
+
)
|
|
260
|
+
return items
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _hidden_included_entries(root: Path, warnings: list[str]) -> list[Path]:
|
|
264
|
+
try:
|
|
265
|
+
return [root / name for name in sorted(os.listdir(root))]
|
|
266
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
267
|
+
return []
|
|
268
|
+
except OSError as error:
|
|
269
|
+
warnings.append(f"cannot read {root}: {error.strerror}")
|
|
270
|
+
return []
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _plugin_manifest(plugin_dir: Path) -> Path:
|
|
274
|
+
path = plugin_dir / "plugin.json"
|
|
275
|
+
return path if os.path.exists(path) else plugin_dir / ".antigravity-plugin" / "plugin.json"
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _plugin_status(name: str, states: dict, state_ok: bool, default: Status) -> Status:
|
|
279
|
+
if not state_ok:
|
|
280
|
+
return Status.UNKNOWN
|
|
281
|
+
entry = states.get(name)
|
|
282
|
+
enabled = entry.get("enabled", True) if isinstance(entry, dict) else True
|
|
283
|
+
return Status.DISABLED if enabled is False else default
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _workspace_status(settings: dict, settings_ok: bool, cwd: Path) -> Status:
|
|
287
|
+
if not settings_ok:
|
|
288
|
+
return Status.UNKNOWN
|
|
289
|
+
trusted = settings.get("trustedWorkspaces")
|
|
290
|
+
real = os.path.realpath(cwd)
|
|
291
|
+
if isinstance(trusted, list) and any(
|
|
292
|
+
isinstance(path, str) and os.path.realpath(path) == real for path in trusted
|
|
293
|
+
):
|
|
294
|
+
return Status.ENABLED
|
|
295
|
+
return Status.PENDING
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _server_details(entry: object) -> dict[str, object] | None:
|
|
299
|
+
if not isinstance(entry, dict):
|
|
300
|
+
return None
|
|
301
|
+
if isinstance(entry.get("command"), str):
|
|
302
|
+
return stdio_details(entry["command"], entry.get("env"))
|
|
303
|
+
url = entry.get("serverUrl", entry.get("url"))
|
|
304
|
+
if isinstance(url, str):
|
|
305
|
+
return remote_details("streamable_http", url, entry.get("headers"))
|
|
306
|
+
return None
|
agt_env/adapters/base.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""The adapter interface and helpers shared by adapters."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Protocol
|
|
10
|
+
|
|
11
|
+
from agt_env.jsonc import loads_jsonc
|
|
12
|
+
from agt_env.model import AgentReport, Finding, Item, Kind, Scope, Status
|
|
13
|
+
from agt_env.skills import FoundSkill
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Context:
|
|
18
|
+
home: Path
|
|
19
|
+
cwd: Path
|
|
20
|
+
env: Mapping[str, str]
|
|
21
|
+
# Prefix for machine-wide locations, so tests never read the real system.
|
|
22
|
+
system_root: Path = field(default=Path("/"))
|
|
23
|
+
|
|
24
|
+
def on_path(self, binary: str) -> bool:
|
|
25
|
+
"""Check PATH without executing anything; shims count as installed."""
|
|
26
|
+
return shutil.which(binary, path=self.env.get("PATH", "")) is not None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
INVALID_SERVER = Finding("invalid server entry")
|
|
30
|
+
MISSING_PLUGIN = Finding("plugin files are missing")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Adapter(Protocol):
|
|
34
|
+
id: str
|
|
35
|
+
|
|
36
|
+
def scan(self, context: Context) -> AgentReport: ...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def config_dir(context: Context, variable: str, default: Path) -> tuple[Path, str | None]:
|
|
40
|
+
"""Return an agent's config directory and the variable that moved it, if any."""
|
|
41
|
+
value = context.env.get(variable)
|
|
42
|
+
if not value:
|
|
43
|
+
return default, None
|
|
44
|
+
path = Path(value).expanduser()
|
|
45
|
+
return path, variable if os.path.realpath(path) != os.path.realpath(default) else None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def read_json(
|
|
49
|
+
path: Path, warnings: list[str], *, jsonc: bool = False, blank_ok: bool = False
|
|
50
|
+
) -> tuple[dict, bool]:
|
|
51
|
+
"""Return the file's top-level object and whether it could be read.
|
|
52
|
+
|
|
53
|
+
A missing file is normal. A non-object is treated as empty.
|
|
54
|
+
"""
|
|
55
|
+
if not os.path.isfile(path):
|
|
56
|
+
return {}, True
|
|
57
|
+
try:
|
|
58
|
+
with open(path, encoding="utf-8") as file:
|
|
59
|
+
text = file.read()
|
|
60
|
+
if blank_ok and not text.strip():
|
|
61
|
+
return {}, True
|
|
62
|
+
data = loads_jsonc(text) if jsonc else json.loads(text)
|
|
63
|
+
except (OSError, ValueError) as error:
|
|
64
|
+
warnings.append(f"cannot read {path}: {error}")
|
|
65
|
+
return {}, False
|
|
66
|
+
return (data if isinstance(data, dict) else {}), True
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def first_existing(*paths: Path) -> Path | None:
|
|
70
|
+
return next((path for path in paths if os.path.exists(path)), None)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def list_entries(directory: Path, warnings: list[str]) -> list[Path]:
|
|
74
|
+
"""Return a directory's non-hidden entries. Only an unreadable directory warns."""
|
|
75
|
+
try:
|
|
76
|
+
with os.scandir(directory) as iterator:
|
|
77
|
+
names = sorted(entry.name for entry in iterator)
|
|
78
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
79
|
+
return []
|
|
80
|
+
except OSError as error:
|
|
81
|
+
warnings.append(f"cannot read {directory}: {error.strerror}")
|
|
82
|
+
return []
|
|
83
|
+
return [directory / name for name in names if not name.startswith(".")]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def skill_item(
|
|
87
|
+
agent: str, skill: FoundSkill, scope: Scope, status: Status, problem: str = ""
|
|
88
|
+
) -> Item:
|
|
89
|
+
"""Build a skill item. `problem` is why the agent refuses it, if it was readable."""
|
|
90
|
+
fields = skill.fields or {}
|
|
91
|
+
return Item(
|
|
92
|
+
name=fields.get("name") or skill.default_name,
|
|
93
|
+
kind=Kind.SKILL,
|
|
94
|
+
native_kind="skill",
|
|
95
|
+
agent=agent,
|
|
96
|
+
scope=scope,
|
|
97
|
+
status=status,
|
|
98
|
+
description=fields.get("description", ""),
|
|
99
|
+
source_path=skill.path,
|
|
100
|
+
resolved_path=skill.resolved_path,
|
|
101
|
+
findings=[Finding(skill.problem or problem)] if status is Status.BROKEN else [],
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def definition_item(
|
|
106
|
+
agent: str,
|
|
107
|
+
found: FoundSkill,
|
|
108
|
+
kind: Kind,
|
|
109
|
+
native_kind: str,
|
|
110
|
+
scope: Scope,
|
|
111
|
+
status: Status,
|
|
112
|
+
*,
|
|
113
|
+
name: str,
|
|
114
|
+
description: str = "",
|
|
115
|
+
problem: str = "",
|
|
116
|
+
) -> Item:
|
|
117
|
+
"""Build an item for a Markdown command or agent. `problem` is why the agent refuses it."""
|
|
118
|
+
if found.broken:
|
|
119
|
+
status = Status.BROKEN
|
|
120
|
+
return Item(
|
|
121
|
+
name=name,
|
|
122
|
+
kind=kind,
|
|
123
|
+
native_kind=native_kind,
|
|
124
|
+
agent=agent,
|
|
125
|
+
scope=scope,
|
|
126
|
+
status=status,
|
|
127
|
+
description=description,
|
|
128
|
+
source_path=found.path,
|
|
129
|
+
resolved_path=found.resolved_path,
|
|
130
|
+
findings=[Finding(found.problem or problem)] if status is Status.BROKEN else [],
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def first_line(text: str) -> str:
|
|
135
|
+
return next((line.strip() for line in text.splitlines() if line.strip()), "")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def mcp_item(
|
|
139
|
+
agent: str, name: str, details: dict | None, scope: Scope, status: Status, path: Path
|
|
140
|
+
) -> Item:
|
|
141
|
+
"""Build a server item. Details of None mean the entry is invalid."""
|
|
142
|
+
return Item(
|
|
143
|
+
name=name,
|
|
144
|
+
kind=Kind.MCP,
|
|
145
|
+
native_kind="mcp server",
|
|
146
|
+
agent=agent,
|
|
147
|
+
scope=scope,
|
|
148
|
+
status=Status.BROKEN if details is None else status,
|
|
149
|
+
description="",
|
|
150
|
+
source_path=path,
|
|
151
|
+
resolved_path=Path(os.path.realpath(path)),
|
|
152
|
+
details=details or {},
|
|
153
|
+
findings=[INVALID_SERVER] if details is None else [],
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def plugin_item(
|
|
158
|
+
agent: str,
|
|
159
|
+
name: str,
|
|
160
|
+
native_kind: str,
|
|
161
|
+
scope: Scope,
|
|
162
|
+
status: Status,
|
|
163
|
+
path: Path,
|
|
164
|
+
description: str = "",
|
|
165
|
+
) -> Item:
|
|
166
|
+
return Item(
|
|
167
|
+
name=name,
|
|
168
|
+
kind=Kind.PLUGIN,
|
|
169
|
+
native_kind=native_kind,
|
|
170
|
+
agent=agent,
|
|
171
|
+
scope=scope,
|
|
172
|
+
status=status,
|
|
173
|
+
description=description,
|
|
174
|
+
source_path=path,
|
|
175
|
+
resolved_path=Path(os.path.realpath(path)),
|
|
176
|
+
findings=[MISSING_PLUGIN] if status is Status.BROKEN else [],
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def loads_skills(plugin: Item) -> bool:
|
|
181
|
+
"""A plugin that's turned off, replaced, or missing contributes no skills."""
|
|
182
|
+
return plugin.status not in (Status.DISABLED, Status.SHADOWED, Status.BROKEN)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def marketplace_suffix(plugin_id: str) -> str:
|
|
186
|
+
"""Return the `@marketplace` of a plugin ID."""
|
|
187
|
+
name, at, marketplace = plugin_id.rpartition("@")
|
|
188
|
+
return f"{at}{marketplace}" if name else ""
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def mark_bundled(
|
|
192
|
+
skill: Item,
|
|
193
|
+
plugin: Item,
|
|
194
|
+
name: str,
|
|
195
|
+
native_kind: str = "plugin skill",
|
|
196
|
+
*,
|
|
197
|
+
prefix: str = "",
|
|
198
|
+
) -> Item:
|
|
199
|
+
"""Make an item report the plugin it came from, under the name the agent gives it.
|
|
200
|
+
|
|
201
|
+
`prefix` is the part of the name that the plugin adds.
|
|
202
|
+
"""
|
|
203
|
+
skill.name = name
|
|
204
|
+
skill.name_prefix = prefix
|
|
205
|
+
skill.native_kind = native_kind
|
|
206
|
+
skill.details["plugin"] = plugin.name
|
|
207
|
+
if skill.status is Status.ENABLED:
|
|
208
|
+
skill.status = plugin.status
|
|
209
|
+
return skill
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def listed_paths(value: object) -> list[str]:
|
|
213
|
+
"""Read a manifest field that holds a path or a list of paths."""
|
|
214
|
+
values = value if isinstance(value, list) else [value]
|
|
215
|
+
return [entry for entry in values if isinstance(entry, str)]
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def is_inside_plugin(path: str) -> bool:
|
|
219
|
+
"""Whether a manifest path has the form `./something`, staying inside the plugin."""
|
|
220
|
+
return path.startswith("./") and ".." not in Path(path).parts
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def mark_shadowed(
|
|
224
|
+
ranked: Iterable[Item],
|
|
225
|
+
*,
|
|
226
|
+
key: Callable[[Item], str] = lambda item: item.name,
|
|
227
|
+
winners: frozenset[Status] = frozenset({Status.ENABLED}),
|
|
228
|
+
) -> None:
|
|
229
|
+
"""Mark what loses to a same-named item. `ranked` is highest precedence first.
|
|
230
|
+
|
|
231
|
+
`winners` is the statuses that take the name. In some agents a turned-off item still does.
|
|
232
|
+
"""
|
|
233
|
+
won: dict[str, Item] = {}
|
|
234
|
+
for item in ranked:
|
|
235
|
+
winner = won.get(key(item))
|
|
236
|
+
if winner is None:
|
|
237
|
+
if item.status in winners:
|
|
238
|
+
won[key(item)] = item
|
|
239
|
+
elif item.status is Status.ENABLED:
|
|
240
|
+
item.status = Status.SHADOWED
|
|
241
|
+
item.findings.append(Finding("shadowed by", winner.source_path))
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def load_once(items: list[Item]) -> list[Item]:
|
|
245
|
+
"""Keep the first skill per real path: agents load a linked skill only once."""
|
|
246
|
+
seen: set[Path] = set()
|
|
247
|
+
unique = []
|
|
248
|
+
for item in items:
|
|
249
|
+
if item.kind is not Kind.SKILL or item.status is Status.BROKEN:
|
|
250
|
+
unique.append(item)
|
|
251
|
+
elif item.resolved_path not in seen:
|
|
252
|
+
seen.add(item.resolved_path)
|
|
253
|
+
unique.append(item)
|
|
254
|
+
return unique
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def unique_roots(roots: list[tuple[Scope, Path]]) -> list[tuple[Scope, Path]]:
|
|
258
|
+
"""Drop roots that are the same directory, keeping the first (cwd may be HOME)."""
|
|
259
|
+
seen: set[str] = set()
|
|
260
|
+
unique = []
|
|
261
|
+
for label, root in roots:
|
|
262
|
+
real = os.path.realpath(root)
|
|
263
|
+
if real not in seen:
|
|
264
|
+
seen.add(real)
|
|
265
|
+
unique.append((label, root))
|
|
266
|
+
return unique
|