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 ADDED
@@ -0,0 +1,3 @@
1
+ """List the capabilities available to each coding agent on this machine."""
2
+
3
+ __version__ = "0.0.3"
agt_env/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from agt_env.cli import main
4
+
5
+ sys.exit(main())
@@ -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
@@ -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