ama-cli 0.1.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.
ama_cli/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Ama CLI — connect your coding agent to Ama.
2
+
3
+ The public, thin CLI. It speaks HTTP to a hosted or self-hosted Ama and never
4
+ imports server code (see the `public-cli-isolation` contract in .importlinter).
5
+ """
6
+
7
+ __version__ = "0.1.0"
@@ -0,0 +1,25 @@
1
+ """Coding-agent detection and MCP registration."""
2
+
3
+ from ama_cli.agents.base import (
4
+ AgentSpec,
5
+ Confidence,
6
+ Detection,
7
+ Env,
8
+ Platform,
9
+ Signal,
10
+ SignalKind,
11
+ )
12
+ from ama_cli.agents.registry import REGISTRY, detect_all, get_spec
13
+
14
+ __all__ = [
15
+ "REGISTRY",
16
+ "AgentSpec",
17
+ "Confidence",
18
+ "Detection",
19
+ "Env",
20
+ "Platform",
21
+ "Signal",
22
+ "SignalKind",
23
+ "detect_all",
24
+ "get_spec",
25
+ ]
@@ -0,0 +1,48 @@
1
+ """The adapter registry.
2
+
3
+ v1 covers Claude Code, Cursor, Codex, Gemini CLI and VS Code. Windsurf, Zed,
4
+ Cline and Roo are detected but not yet registerable — adapters are cheap once
5
+ the seam exists, but each needs testing against a real install, and that is the
6
+ actual cost. `ama mcp add` says so rather than failing obscurely.
7
+
8
+ Zed is the only remaining target needing a comment-preserving (JSONC) writer;
9
+ every v1 agent is either CLI-backed or plain JSON.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from ama_cli.agents.adapters.cli_backed import (
15
+ ClaudeCodeAdapter,
16
+ CodexAdapter,
17
+ GeminiAdapter,
18
+ VSCodeAdapter,
19
+ )
20
+ from ama_cli.agents.adapters.cursor import CursorAdapter
21
+ from ama_cli.agents.mcp import AgentAdapter
22
+
23
+ ADAPTERS: tuple[AgentAdapter, ...] = (
24
+ ClaudeCodeAdapter(),
25
+ CursorAdapter(),
26
+ CodexAdapter(),
27
+ GeminiAdapter(),
28
+ VSCodeAdapter(),
29
+ )
30
+
31
+ ADAPTERS_BY_ID: dict[str, AgentAdapter] = {a.spec.id: a for a in ADAPTERS}
32
+
33
+
34
+ def get_adapter(agent_id: str) -> AgentAdapter | None:
35
+ """Look up an adapter by agent id, or None when the agent has no adapter yet."""
36
+ return ADAPTERS_BY_ID.get(agent_id)
37
+
38
+
39
+ __all__ = [
40
+ "ADAPTERS",
41
+ "ADAPTERS_BY_ID",
42
+ "ClaudeCodeAdapter",
43
+ "CodexAdapter",
44
+ "CursorAdapter",
45
+ "GeminiAdapter",
46
+ "VSCodeAdapter",
47
+ "get_adapter",
48
+ ]
@@ -0,0 +1,271 @@
1
+ """Adapters for agents that ship their own non-interactive MCP CLI.
2
+
3
+ Preferred over writing config files: the vendor's CLI owns the dialect, so it
4
+ insulates us from schema and path churn. It matters most for VS Code, whose docs
5
+ describe the user `mcp.json` as living "in your user profile" but never state OS
6
+ paths — and those paths break under named profiles, Insiders and portable
7
+ installs anyway.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from ama_cli.agents.base import Env
13
+ from ama_cli.agents.mcp import (
14
+ Plan,
15
+ RemoteServer,
16
+ RunCommand,
17
+ Scope,
18
+ unsupported_plan,
19
+ )
20
+ from ama_cli.agents.registry import CLAUDE_CODE, CODEX, GEMINI_CLI, VSCODE
21
+
22
+
23
+ def _replace_first(argv: tuple[str, ...]) -> RunCommand:
24
+ """A pre-emptive remove, so a re-run replaces rather than erroring.
25
+
26
+ None of these CLIs have a --force, and `mcp add` errors when the server
27
+ already exists -- so re-running onboard would fail without this. Replace
28
+ rather than skip: someone re-onboarding after rotating their key must end up
29
+ with the new token, not silently keep the old one.
30
+
31
+ Tolerates failure because on a clean machine there is nothing to remove.
32
+ """
33
+ return RunCommand(argv, tolerate_failure=True)
34
+
35
+
36
+ class ClaudeCodeAdapter:
37
+ """Claude Code, via `claude mcp add`.
38
+
39
+ Scopes are Claude's own: `local` (default -- this project only), `project`
40
+ (.mcp.json, VCS-shared), `user` (all projects). We map GLOBAL -> user.
41
+ """
42
+
43
+ spec = CLAUDE_CODE
44
+
45
+ def plan_add(self, server: RemoteServer, scope: Scope, env: Env) -> Plan:
46
+ if not env.which("claude"):
47
+ return unsupported_plan(self.spec.id, "claude is not on PATH")
48
+
49
+ if scope is Scope.PROJECT:
50
+ # Claude expands ${VAR} and ${VAR:-default} in url and headers, so
51
+ # .mcp.json can be committed without leaking the token.
52
+ auth = server.authorization(scope, env_ref=f"${{{server.token_env_var}}}")
53
+ else:
54
+ auth = server.authorization(scope)
55
+
56
+ # `-s user` is required for global: `claude mcp add` defaults to
57
+ # `-s local`, which would scope the server to this directory only.
58
+ claude_scope = "project" if scope is Scope.PROJECT else "user"
59
+ argv = (
60
+ "claude",
61
+ "mcp",
62
+ "add",
63
+ "--transport",
64
+ "http",
65
+ "-s",
66
+ claude_scope,
67
+ server.name,
68
+ server.url,
69
+ "--header",
70
+ f"Authorization: {auth}",
71
+ )
72
+ return Plan(
73
+ self.spec.id,
74
+ steps=(
75
+ _replace_first(("claude", "mcp", "remove", "-s", claude_scope, server.name)),
76
+ RunCommand(argv, secret=server.token),
77
+ ),
78
+ )
79
+
80
+ def plan_remove(self, name: str, scope: Scope, env: Env) -> Plan:
81
+ if not env.which("claude"):
82
+ return unsupported_plan(self.spec.id, "claude is not on PATH")
83
+ claude_scope = "project" if scope is Scope.PROJECT else "user"
84
+ return Plan(
85
+ self.spec.id,
86
+ steps=(RunCommand(("claude", "mcp", "remove", "-s", claude_scope, name)),),
87
+ )
88
+
89
+ def list_servers(self, scope: Scope, env: Env) -> list[str]:
90
+ return _list_via_cli(("claude", "mcp", "list"), env, "claude")
91
+
92
+
93
+ class GeminiAdapter:
94
+ """Gemini CLI, via `gemini mcp add`.
95
+
96
+ Gemini distinguishes transport by *key*: `httpUrl` is streamable HTTP while
97
+ `url` is SSE. Using `url` for an HTTP endpoint is the classic Gemini
98
+ misconfiguration -- `--transport http` is what keeps us on the right one.
99
+ """
100
+
101
+ spec = GEMINI_CLI
102
+
103
+ def plan_add(self, server: RemoteServer, scope: Scope, env: Env) -> Plan:
104
+ if not env.which("gemini"):
105
+ return unsupported_plan(self.spec.id, "gemini is not on PATH")
106
+
107
+ auth = (
108
+ server.authorization(scope, env_ref=f"${{{server.token_env_var}}}")
109
+ if scope is Scope.PROJECT
110
+ else server.authorization(scope)
111
+ )
112
+ argv = (
113
+ "gemini",
114
+ "mcp",
115
+ "add",
116
+ "--transport",
117
+ "http",
118
+ "-s",
119
+ "project" if scope is Scope.PROJECT else "user",
120
+ "--header",
121
+ f"Authorization: {auth}",
122
+ server.name,
123
+ server.url,
124
+ )
125
+ # Deliberately not passing --trust: it bypasses tool-call confirmation,
126
+ # which is not ours to switch off silently.
127
+ return Plan(
128
+ self.spec.id,
129
+ steps=(
130
+ _replace_first(("gemini", "mcp", "remove", server.name)),
131
+ RunCommand(argv, secret=server.token),
132
+ ),
133
+ )
134
+
135
+ def plan_remove(self, name: str, scope: Scope, env: Env) -> Plan:
136
+ if not env.which("gemini"):
137
+ return unsupported_plan(self.spec.id, "gemini is not on PATH")
138
+ return Plan(self.spec.id, steps=(RunCommand(("gemini", "mcp", "remove", name)),))
139
+
140
+ def list_servers(self, scope: Scope, env: Env) -> list[str]:
141
+ return _list_via_cli(("gemini", "mcp", "list"), env, "gemini")
142
+
143
+
144
+ class CodexAdapter:
145
+ """Codex CLI, via `codex mcp add`.
146
+
147
+ Two things make Codex the odd one out:
148
+
149
+ 1. It is global-only -- no project config exists.
150
+ 2. Bearer tokens are env-var-indirected *by design*: it stores the variable
151
+ name, never the token. So registration alone is not enough -- AMA_API_KEY
152
+ must also be exported in the user's shell, or the server silently fails
153
+ to authenticate.
154
+ """
155
+
156
+ spec = CODEX
157
+
158
+ def plan_add(self, server: RemoteServer, scope: Scope, env: Env) -> Plan:
159
+ if not env.which("codex"):
160
+ return unsupported_plan(self.spec.id, "codex is not on PATH")
161
+ if scope is Scope.PROJECT:
162
+ return unsupported_plan(
163
+ self.spec.id, "Codex has no project-level config; it is global-only"
164
+ )
165
+ argv = (
166
+ "codex",
167
+ "mcp",
168
+ "add",
169
+ server.name,
170
+ "--url",
171
+ server.url,
172
+ "--bearer-token-env-var",
173
+ server.token_env_var,
174
+ )
175
+ # No secret= : the token never reaches this command by design.
176
+ return Plan(
177
+ self.spec.id,
178
+ steps=(
179
+ _replace_first(("codex", "mcp", "remove", server.name)),
180
+ RunCommand(argv),
181
+ ),
182
+ )
183
+
184
+ def plan_remove(self, name: str, scope: Scope, env: Env) -> Plan:
185
+ if not env.which("codex"):
186
+ return unsupported_plan(self.spec.id, "codex is not on PATH")
187
+ return Plan(self.spec.id, steps=(RunCommand(("codex", "mcp", "remove", name)),))
188
+
189
+ def list_servers(self, scope: Scope, env: Env) -> list[str]:
190
+ return _list_via_cli(("codex", "mcp", "list"), env, "codex")
191
+
192
+
193
+ class VSCodeAdapter:
194
+ """VS Code / Copilot, via `code --add-mcp`.
195
+
196
+ VS Code's root key is `servers`, not `mcpServers` -- the single biggest
197
+ schema divergence in the ecosystem. We never write that file: `--add-mcp`
198
+ does, and it knows where the user's profile actually is.
199
+ """
200
+
201
+ spec = VSCODE
202
+
203
+ def _binary(self, env: Env) -> str | None:
204
+ for candidate in ("code", "code-insiders"):
205
+ if env.which(candidate):
206
+ return candidate
207
+ return None
208
+
209
+ def plan_add(self, server: RemoteServer, scope: Scope, env: Env) -> Plan:
210
+ binary = self._binary(env)
211
+ if not binary:
212
+ return unsupported_plan(self.spec.id, "code is not on PATH")
213
+ if scope is Scope.PROJECT:
214
+ return unsupported_plan(
215
+ self.spec.id,
216
+ "`code --add-mcp` writes user scope only; project scope needs .vscode/mcp.json",
217
+ )
218
+
219
+ import json as _json
220
+
221
+ payload = _json.dumps(
222
+ {
223
+ "name": server.name,
224
+ "type": "http",
225
+ "url": server.url,
226
+ "headers": {"Authorization": server.authorization(scope)},
227
+ }
228
+ )
229
+ return Plan(
230
+ self.spec.id,
231
+ steps=(RunCommand((binary, "--add-mcp", payload), secret=server.token),),
232
+ )
233
+
234
+ def plan_remove(self, name: str, scope: Scope, env: Env) -> Plan:
235
+ return unsupported_plan(
236
+ self.spec.id,
237
+ "VS Code has no MCP remove command; use the MCP: Open User Configuration command",
238
+ )
239
+
240
+ def list_servers(self, scope: Scope, env: Env) -> list[str]:
241
+ # No documented list command; the config lives at a path VS Code's docs
242
+ # never state. Reporting nothing beats guessing wrong.
243
+ return []
244
+
245
+
246
+ def _list_via_cli(argv: tuple[str, ...], env: Env, binary: str) -> list[str]:
247
+ """Parse server names out of an agent's `mcp list` output.
248
+
249
+ Best-effort by construction: these are human-readable listings with no
250
+ documented machine format, so this reports what it can recognise and stays
251
+ quiet otherwise rather than inventing structure.
252
+ """
253
+ import re
254
+ import subprocess
255
+
256
+ if not env.which(binary):
257
+ return []
258
+ try:
259
+ proc = subprocess.run(argv, capture_output=True, text=True, timeout=20)
260
+ except (OSError, subprocess.SubprocessError):
261
+ return []
262
+ if proc.returncode != 0:
263
+ return []
264
+
265
+ names: list[str] = []
266
+ for line in proc.stdout.splitlines():
267
+ # Matches the common "name: url" / "name url" listing shapes.
268
+ m = re.match(r"^\s*([A-Za-z0-9_-]+)\s*[::]\s*\S", line)
269
+ if m:
270
+ names.append(m.group(1))
271
+ return names
@@ -0,0 +1,86 @@
1
+ """Cursor adapter — writes mcp.json directly.
2
+
3
+ Cursor has no `mcp add` subcommand (`cursor-agent mcp` offers only list,
4
+ list-tools, login, enable, disable), so this is the one v1 agent whose config we
5
+ write ourselves. Both the editor and the CLI read the same file.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ from ama_cli.agents.base import Env
13
+ from ama_cli.agents.mcp import (
14
+ FileWrite,
15
+ Plan,
16
+ RemoteServer,
17
+ Scope,
18
+ dump_json,
19
+ read_json,
20
+ )
21
+ from ama_cli.agents.registry import CURSOR
22
+
23
+ ROOT_KEY = "mcpServers"
24
+
25
+
26
+ class CursorAdapter:
27
+ """Cursor, via ~/.cursor/mcp.json (global) or .cursor/mcp.json (project).
28
+
29
+ Dialect note: Cursor identifies a remote server by the *presence of `url`*
30
+ and has no `type` field for it. Community snippets add
31
+ `"type": "streamable-http"`, but no official remote example includes one, so
32
+ we emit exactly what the docs show and nothing more. Guessing at an
33
+ undocumented discriminator is how you get a config that silently never
34
+ connects.
35
+ """
36
+
37
+ spec = CURSOR
38
+
39
+ def config_path(self, scope: Scope, env: Env) -> Path:
40
+ if scope is Scope.PROJECT:
41
+ return env.cwd / ".cursor" / "mcp.json"
42
+ return env.home / ".cursor" / "mcp.json"
43
+
44
+ def _entry(self, server: RemoteServer, scope: Scope) -> dict:
45
+ # ${env:VAR} is Cursor's interpolation syntax, resolved in url and
46
+ # headers. Project scope must use it -- that file gets committed.
47
+ env_ref = f"${{env:{server.token_env_var}}}"
48
+ return {
49
+ "url": server.url,
50
+ "headers": {"Authorization": server.authorization(scope, env_ref=env_ref)},
51
+ }
52
+
53
+ def plan_add(self, server: RemoteServer, scope: Scope, env: Env) -> Plan:
54
+ path = self.config_path(scope, env)
55
+ data, raw = read_json(path)
56
+
57
+ servers = dict(data.get(ROOT_KEY) or {})
58
+ servers[server.name] = self._entry(server, scope)
59
+ merged = {**data, ROOT_KEY: servers}
60
+
61
+ return Plan(
62
+ self.spec.id,
63
+ steps=(FileWrite(path=path, before=raw, after=dump_json(merged), secret=server.token),),
64
+ )
65
+
66
+ def plan_remove(self, name: str, scope: Scope, env: Env) -> Plan:
67
+ path = self.config_path(scope, env)
68
+ data, raw = read_json(path)
69
+
70
+ servers = dict(data.get(ROOT_KEY) or {})
71
+ if name not in servers:
72
+ return Plan(self.spec.id) # already absent -- nothing to do
73
+ servers.pop(name)
74
+ merged = {**data, ROOT_KEY: servers}
75
+
76
+ return Plan(
77
+ self.spec.id,
78
+ steps=(FileWrite(path=path, before=raw, after=dump_json(merged)),),
79
+ )
80
+
81
+ def list_servers(self, scope: Scope, env: Env) -> list[str]:
82
+ try:
83
+ data, _ = read_json(self.config_path(scope, env))
84
+ except ValueError:
85
+ return []
86
+ return sorted((data.get(ROOT_KEY) or {}).keys())
ama_cli/agents/base.py ADDED
@@ -0,0 +1,206 @@
1
+ """Core types for coding-agent detection.
2
+
3
+ Detection answers one question: *which coding agents does this developer
4
+ actually use?* We then offer to register the Ama MCP server with each.
5
+
6
+ Everything here is a pure function of an injected `Env`. Nothing calls
7
+ `Path.home()`, `shutil.which`, or reads `sys.platform` at module scope, so
8
+ tests build a temp home and a fake PATH and need no monkeypatching.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import shutil
15
+ import sys
16
+ from collections.abc import Callable, Sequence
17
+ from dataclasses import dataclass, field
18
+ from enum import Enum, IntEnum
19
+ from pathlib import Path
20
+
21
+
22
+ class Platform(str, Enum):
23
+ """Host operating system, normalised."""
24
+
25
+ DARWIN = "darwin"
26
+ LINUX = "linux"
27
+ WINDOWS = "windows"
28
+
29
+ @classmethod
30
+ def current(cls) -> Platform:
31
+ """Map sys.platform onto the three cases we care about."""
32
+ if sys.platform == "darwin":
33
+ return cls.DARWIN
34
+ if sys.platform.startswith("win"):
35
+ return cls.WINDOWS
36
+ return cls.LINUX
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class Env:
41
+ """The host, injected rather than discovered.
42
+
43
+ `which` is a callable so tests can present a fake PATH without touching
44
+ the real one. `appdata` is only meaningful on Windows.
45
+ """
46
+
47
+ home: Path
48
+ cwd: Path
49
+ platform: Platform
50
+ which: Callable[[str], str | None] = shutil.which
51
+ appdata: Path | None = None
52
+ xdg_config_home: Path | None = None
53
+
54
+ @classmethod
55
+ def current(cls) -> Env:
56
+ """Build an Env from the real host.
57
+
58
+ The only place environment lookup happens. Everything downstream reads
59
+ the resolved Env, which is what keeps detectors testable.
60
+ """
61
+ appdata = os.environ.get("APPDATA")
62
+ xdg = os.environ.get("XDG_CONFIG_HOME")
63
+ return cls(
64
+ home=Path.home(),
65
+ cwd=Path.cwd(),
66
+ platform=Platform.current(),
67
+ which=shutil.which,
68
+ appdata=Path(appdata) if appdata else None,
69
+ xdg_config_home=Path(xdg) if xdg else None,
70
+ )
71
+
72
+ @property
73
+ def config_home(self) -> Path:
74
+ """$XDG_CONFIG_HOME, defaulting to ~/.config per the XDG spec."""
75
+ return self.xdg_config_home or self.home / ".config"
76
+
77
+ def display(self, path: Path) -> str:
78
+ """Render a path as ~/… when it sits under home, for UI output."""
79
+ try:
80
+ return f"~/{path.relative_to(self.home)}"
81
+ except ValueError:
82
+ return str(path)
83
+
84
+
85
+ class Confidence(IntEnum):
86
+ """How strongly a signal implies *this developer uses this agent here*.
87
+
88
+ Ordered, so `max()` over a detection's signals gives its confidence.
89
+
90
+ MACHINE beats BINARY because a config directory in home means the agent has
91
+ actually run on this machine, whereas a binary may be an unused leftover.
92
+ REPO is last and deliberately weak: `CLAUDE.md` means *some contributor*
93
+ once used Claude Code, which says nothing about the person running onboard.
94
+ """
95
+
96
+ REPO = 1
97
+ BINARY = 2
98
+ MACHINE = 3
99
+
100
+
101
+ class SignalKind(str, Enum):
102
+ """What kind of evidence a signal represents."""
103
+
104
+ MACHINE_DIR = "machine_dir"
105
+ BINARY = "binary"
106
+ REPO_PATH = "repo_path"
107
+
108
+
109
+ _CONFIDENCE_BY_KIND = {
110
+ SignalKind.MACHINE_DIR: Confidence.MACHINE,
111
+ SignalKind.BINARY: Confidence.BINARY,
112
+ SignalKind.REPO_PATH: Confidence.REPO,
113
+ }
114
+
115
+
116
+ @dataclass(frozen=True)
117
+ class Signal:
118
+ """One piece of evidence, with a human-readable reason for the UI.
119
+
120
+ `detail` is shown to the user verbatim — they should never have to wonder
121
+ why an agent appeared in the list.
122
+ """
123
+
124
+ kind: SignalKind
125
+ detail: str
126
+
127
+ @property
128
+ def confidence(self) -> Confidence:
129
+ """The confidence this kind of signal carries."""
130
+ return _CONFIDENCE_BY_KIND[self.kind]
131
+
132
+
133
+ @dataclass(frozen=True)
134
+ class Detection:
135
+ """The result of running one agent's detector against an Env."""
136
+
137
+ agent_id: str
138
+ display_name: str
139
+ signals: tuple[Signal, ...] = ()
140
+
141
+ @property
142
+ def detected(self) -> bool:
143
+ """True when any evidence was found."""
144
+ return bool(self.signals)
145
+
146
+ @property
147
+ def confidence(self) -> Confidence | None:
148
+ """The strongest signal's confidence, or None when undetected."""
149
+ return max((s.confidence for s in self.signals), default=None)
150
+
151
+ @property
152
+ def reason(self) -> str:
153
+ """All signal details, comma-joined, for display."""
154
+ return ", ".join(s.detail for s in self.signals)
155
+
156
+
157
+ @dataclass(frozen=True)
158
+ class AgentSpec:
159
+ """A declarative detector for one agent platform.
160
+
161
+ Detection is nearly always "look for a config dir, a binary, some repo
162
+ files", so specs are data. The two callables exist only for genuine
163
+ platform variance (VS Code and Roo bury config under OS-specific
164
+ application-support paths); everything else stays a plain tuple.
165
+
166
+ Adding an agent should mean adding one entry to `registry.py` and a test.
167
+ """
168
+
169
+ id: str
170
+ display_name: str
171
+ docs_url: str
172
+ binaries: tuple[str, ...] = ()
173
+ # Paths relative to home, e.g. ".claude" -> ~/.claude
174
+ machine_paths: tuple[str, ...] = ()
175
+ # Resolved against the Env for platform-specific locations.
176
+ machine_paths_fn: Callable[[Env], Sequence[Path]] | None = field(default=None, repr=False)
177
+ # Paths relative to cwd, e.g. "CLAUDE.md"
178
+ repo_paths: tuple[str, ...] = ()
179
+
180
+ def _machine_candidates(self, env: Env) -> list[Path]:
181
+ paths = [env.home / p for p in self.machine_paths]
182
+ if self.machine_paths_fn is not None:
183
+ paths.extend(self.machine_paths_fn(env))
184
+ return paths
185
+
186
+ def detect(self, env: Env) -> Detection:
187
+ """Gather every signal for this agent. Never raises."""
188
+ signals: list[Signal] = []
189
+
190
+ for path in self._machine_candidates(env):
191
+ if path.exists():
192
+ signals.append(Signal(SignalKind.MACHINE_DIR, env.display(path)))
193
+
194
+ for binary in self.binaries:
195
+ if env.which(binary):
196
+ signals.append(Signal(SignalKind.BINARY, f"{binary} on PATH"))
197
+
198
+ for rel in self.repo_paths:
199
+ if (env.cwd / rel).exists():
200
+ signals.append(Signal(SignalKind.REPO_PATH, f"{rel} in repo"))
201
+
202
+ return Detection(
203
+ agent_id=self.id,
204
+ display_name=self.display_name,
205
+ signals=tuple(signals),
206
+ )