dhub-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.
dhub/cli/runtime.py ADDED
@@ -0,0 +1,87 @@
1
+ """CLI command for running skills locally."""
2
+
3
+ import subprocess
4
+
5
+ import typer
6
+ from rich.console import Console
7
+
8
+ console = Console()
9
+
10
+
11
+ def run_command(
12
+ skill_ref: str = typer.Argument(help="Skill reference: org/skill"),
13
+ extra_args: list[str] = typer.Argument(None, help="Extra arguments to pass to the skill"),
14
+ ) -> None:
15
+ """Run a locally installed skill using its configured runtime."""
16
+ from dhub.core.install import get_dhub_skill_path
17
+ from dhub.core.runtime import (
18
+ build_env_vars,
19
+ build_uv_run_command,
20
+ build_uv_sync_command,
21
+ validate_local_runtime_prerequisites,
22
+ )
23
+ from dhub.core.manifest import parse_skill_md
24
+
25
+ # Parse org/skill reference
26
+ parts = skill_ref.split("/", 1)
27
+ if len(parts) != 2:
28
+ console.print(
29
+ "[red]Error: Skill reference must be in org/skill format.[/]"
30
+ )
31
+ raise typer.Exit(1)
32
+ org_slug, skill_name = parts
33
+
34
+ # Resolve the local skill directory
35
+ skill_dir = get_dhub_skill_path(org_slug, skill_name)
36
+ if not skill_dir.exists():
37
+ console.print(
38
+ f"[red]Error: Skill '{skill_ref}' is not installed. "
39
+ f"Expected at {skill_dir}[/]"
40
+ )
41
+ raise typer.Exit(1)
42
+
43
+ # Parse SKILL.md to get runtime config
44
+ skill_md_path = skill_dir / "SKILL.md"
45
+ if not skill_md_path.exists():
46
+ console.print(
47
+ f"[red]Error: SKILL.md not found in {skill_dir}[/]"
48
+ )
49
+ raise typer.Exit(1)
50
+
51
+ manifest = parse_skill_md(skill_md_path)
52
+
53
+ if manifest.runtime is None:
54
+ console.print(
55
+ "[red]Error: This skill has no runtime configuration.[/]"
56
+ )
57
+ raise typer.Exit(1)
58
+
59
+ if manifest.runtime.driver != "local/uv":
60
+ console.print(
61
+ f"[red]Error: Unsupported runtime driver '{manifest.runtime.driver}'. "
62
+ f"Only 'local/uv' is supported.[/]"
63
+ )
64
+ raise typer.Exit(1)
65
+
66
+ # Validate prerequisites
67
+ errors = validate_local_runtime_prerequisites(skill_dir, manifest.runtime)
68
+ if errors:
69
+ console.print("[red]Runtime prerequisites not met:[/]")
70
+ for error in errors:
71
+ console.print(f" [red]- {error}[/]")
72
+ raise typer.Exit(1)
73
+
74
+ # Build environment variables
75
+ env = build_env_vars(manifest.runtime)
76
+
77
+ # Sync dependencies
78
+ sync_cmd = build_uv_sync_command(skill_dir)
79
+ console.print(f"[dim]Syncing dependencies in {skill_dir}...[/]")
80
+ subprocess.run(sync_cmd, check=True, env=env)
81
+
82
+ # Run the entrypoint
83
+ args_tuple = tuple(extra_args) if extra_args else ()
84
+ run_cmd = build_uv_run_command(skill_dir, manifest.runtime.entrypoint, args_tuple)
85
+ console.print(f"[dim]Running {manifest.name}...[/]")
86
+ result = subprocess.run(run_cmd, env=env)
87
+ raise typer.Exit(result.returncode)
dhub/cli/search.py ADDED
@@ -0,0 +1,36 @@
1
+ """CLI command for natural language skill search."""
2
+
3
+ import httpx
4
+ import typer
5
+ from rich.console import Console
6
+ from rich.panel import Panel
7
+
8
+ console = Console()
9
+
10
+
11
+ def ask_command(
12
+ query: str = typer.Argument(help="Natural language query to search for skills"),
13
+ ) -> None:
14
+ """Search for skills using natural language.
15
+
16
+ Example: dhub ask "analyze A/B test results"
17
+ """
18
+ from dhub.cli.config import get_api_url, get_token
19
+
20
+ with httpx.Client(timeout=30) as client:
21
+ resp = client.get(
22
+ f"{get_api_url()}/v1/search",
23
+ params={"q": query},
24
+ headers={"Authorization": f"Bearer {get_token()}"},
25
+ )
26
+ if resp.status_code == 503:
27
+ console.print("[red]Search is not available (server not configured).[/]")
28
+ raise typer.Exit(1)
29
+ resp.raise_for_status()
30
+ data = resp.json()
31
+
32
+ console.print(Panel(
33
+ data["results"],
34
+ title=f"Results for: {data['query']}",
35
+ border_style="blue",
36
+ ))
dhub/core/__init__.py ADDED
@@ -0,0 +1 @@
1
+
dhub/core/install.py ADDED
@@ -0,0 +1,159 @@
1
+ """Installation utilities for skills.
2
+
3
+ Handles checksum verification, canonical path resolution,
4
+ and symlink management for linking skills to agent directories.
5
+ """
6
+
7
+ import hashlib
8
+ from pathlib import Path
9
+
10
+
11
+ # Mapping of agent names to their skill directories
12
+ AGENT_SKILL_PATHS: dict[str, Path] = {
13
+ "claude": Path.home() / ".claude" / "skills",
14
+ "cursor": Path.home() / ".cursor" / "skills",
15
+ "opencode": Path.home() / ".config" / "opencode" / "skills",
16
+ "gemini": Path.home() / ".gemini" / "skills",
17
+ }
18
+
19
+
20
+ def verify_checksum(data: bytes, expected: str) -> None:
21
+ """Verify that the SHA-256 checksum of data matches the expected value.
22
+
23
+ Args:
24
+ data: The raw bytes to hash.
25
+ expected: The expected hex-encoded SHA-256 digest.
26
+
27
+ Raises:
28
+ ValueError: If the computed checksum does not match.
29
+ """
30
+ actual = hashlib.sha256(data).hexdigest()
31
+ if actual != expected:
32
+ raise ValueError(
33
+ f"Checksum mismatch: expected {expected}, got {actual}."
34
+ )
35
+
36
+
37
+ def get_dhub_skill_path(org: str, skill: str) -> Path:
38
+ """Return the canonical local path for an installed skill.
39
+
40
+ Args:
41
+ org: The organization slug.
42
+ skill: The skill name.
43
+
44
+ Returns:
45
+ Path to ~/.dhub/skills/{org}/{skill}/.
46
+ """
47
+ return Path.home() / ".dhub" / "skills" / org / skill
48
+
49
+
50
+ def get_agent_skill_paths() -> dict[str, Path]:
51
+ """Return a copy of the mapping of agent names to their skill directories."""
52
+ return dict(AGENT_SKILL_PATHS)
53
+
54
+
55
+ def link_skill_to_agent(org: str, skill_name: str, agent: str) -> Path:
56
+ """Create a symlink from the agent's skill directory to the canonical skill path.
57
+
58
+ The symlink is named {org}--{skill_name} inside the agent's skill directory,
59
+ and points to the canonical path under ~/.dhub/skills/{org}/{skill_name}/.
60
+
61
+ Args:
62
+ org: The organization slug.
63
+ skill_name: The skill name.
64
+ agent: The agent name (e.g. "claude", "cursor").
65
+
66
+ Returns:
67
+ The path of the created symlink.
68
+
69
+ Raises:
70
+ ValueError: If the agent name is not recognized.
71
+ FileNotFoundError: If the canonical skill directory does not exist.
72
+ """
73
+ if agent not in AGENT_SKILL_PATHS:
74
+ raise ValueError(
75
+ f"Unknown agent '{agent}'. Known agents: {', '.join(sorted(AGENT_SKILL_PATHS))}."
76
+ )
77
+
78
+ canonical = get_dhub_skill_path(org, skill_name)
79
+ if not canonical.exists():
80
+ raise FileNotFoundError(
81
+ f"Skill directory not found: {canonical}"
82
+ )
83
+
84
+ agent_dir = AGENT_SKILL_PATHS[agent]
85
+ agent_dir.mkdir(parents=True, exist_ok=True)
86
+
87
+ symlink_path = agent_dir / f"{org}--{skill_name}"
88
+
89
+ # Remove existing symlink if present to allow re-linking
90
+ if symlink_path.is_symlink() or symlink_path.exists():
91
+ symlink_path.unlink()
92
+
93
+ symlink_path.symlink_to(canonical)
94
+ return symlink_path
95
+
96
+
97
+ def unlink_skill_from_agent(org: str, skill_name: str, agent: str) -> None:
98
+ """Remove the symlink for a skill from an agent's directory.
99
+
100
+ Args:
101
+ org: The organization slug.
102
+ skill_name: The skill name.
103
+ agent: The agent name.
104
+
105
+ Raises:
106
+ ValueError: If the agent name is not recognized.
107
+ FileNotFoundError: If no symlink exists for this skill/agent combination.
108
+ """
109
+ if agent not in AGENT_SKILL_PATHS:
110
+ raise ValueError(
111
+ f"Unknown agent '{agent}'. Known agents: {', '.join(sorted(AGENT_SKILL_PATHS))}."
112
+ )
113
+
114
+ symlink_path = AGENT_SKILL_PATHS[agent] / f"{org}--{skill_name}"
115
+
116
+ if not symlink_path.is_symlink() and not symlink_path.exists():
117
+ raise FileNotFoundError(
118
+ f"No symlink found at {symlink_path}"
119
+ )
120
+
121
+ symlink_path.unlink()
122
+
123
+
124
+ def link_skill_to_all_agents(org: str, skill_name: str) -> list[str]:
125
+ """Symlink a skill to all known agent directories.
126
+
127
+ Args:
128
+ org: The organization slug.
129
+ skill_name: The skill name.
130
+
131
+ Returns:
132
+ List of agent names that were successfully linked.
133
+ """
134
+ linked: list[str] = []
135
+ for agent in sorted(AGENT_SKILL_PATHS):
136
+ link_skill_to_agent(org, skill_name, agent)
137
+ linked.append(agent)
138
+ return linked
139
+
140
+
141
+ def list_linked_agents(org: str, skill_name: str) -> list[str]:
142
+ """Check which agents have a symlink for this skill.
143
+
144
+ Args:
145
+ org: The organization slug.
146
+ skill_name: The skill name.
147
+
148
+ Returns:
149
+ List of agent names that have a symlink pointing to this skill.
150
+ """
151
+ canonical = get_dhub_skill_path(org, skill_name)
152
+ linked: list[str] = []
153
+
154
+ for agent, agent_dir in sorted(AGENT_SKILL_PATHS.items()):
155
+ symlink_path = agent_dir / f"{org}--{skill_name}"
156
+ if symlink_path.is_symlink() and symlink_path.resolve() == canonical.resolve():
157
+ linked.append(agent)
158
+
159
+ return linked
dhub/core/manifest.py ADDED
@@ -0,0 +1,221 @@
1
+ """SKILL.md parser and validator.
2
+
3
+ Parses SKILL.md files containing YAML frontmatter between --- delimiters
4
+ followed by a markdown body that serves as the agent system prompt.
5
+ """
6
+
7
+ import re
8
+ from pathlib import Path
9
+
10
+ import yaml
11
+
12
+ from dhub.models import (
13
+ AgentTestTarget,
14
+ RuntimeConfig,
15
+ SkillManifest,
16
+ TestingConfig,
17
+ )
18
+
19
+ # Name: 1-64 chars, lowercase alphanumeric + hyphens, no leading/trailing hyphens.
20
+ # Aligned with core/validation.py _SKILL_NAME_PATTERN.
21
+ _NAME_PATTERN = re.compile(r"^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$")
22
+
23
+
24
+ def parse_skill_md(path: Path) -> SkillManifest:
25
+ """Parse a SKILL.md file into a SkillManifest.
26
+
27
+ The file format is YAML frontmatter between --- delimiters,
28
+ followed by the markdown body (the agent system prompt).
29
+
30
+ Raises:
31
+ ValueError: If the file format is invalid or required fields are missing.
32
+ FileNotFoundError: If the path does not exist.
33
+ """
34
+ content = path.read_text()
35
+ frontmatter_str, body = _split_frontmatter(content)
36
+ data = yaml.safe_load(frontmatter_str)
37
+
38
+ if not isinstance(data, dict):
39
+ raise ValueError("Frontmatter must be a YAML mapping.")
40
+
41
+ # Required fields
42
+ name = data.get("name")
43
+ if not name:
44
+ raise ValueError("Required field 'name' is missing.")
45
+ if not isinstance(name, str) or not _NAME_PATTERN.match(name):
46
+ raise ValueError(
47
+ f"Invalid name '{name}': must be 1-64 chars, lowercase "
48
+ "alphanumeric + hyphens, no leading/trailing hyphens."
49
+ )
50
+
51
+ description = data.get("description")
52
+ if not description:
53
+ raise ValueError("Required field 'description' is missing.")
54
+ if not isinstance(description, str) or len(description) > 1024:
55
+ raise ValueError(
56
+ "Description must be a string of 1-1024 characters."
57
+ )
58
+
59
+ # Optional scalar fields
60
+ license_val = data.get("license")
61
+ compatibility = data.get("compatibility")
62
+ metadata = data.get("metadata")
63
+ allowed_tools = data.get("allowed_tools")
64
+
65
+ # Optional structured blocks
66
+ runtime = _parse_runtime(data.get("runtime"))
67
+ testing = _parse_testing(data.get("testing"))
68
+ manifest = SkillManifest(
69
+ name=name,
70
+ description=description,
71
+ license=license_val,
72
+ compatibility=compatibility,
73
+ metadata=metadata,
74
+ allowed_tools=allowed_tools,
75
+ runtime=runtime,
76
+ testing=testing,
77
+ body=body,
78
+ )
79
+
80
+ errors = validate_manifest(manifest)
81
+ if errors:
82
+ raise ValueError(
83
+ "Manifest validation failed:\n" + "\n".join(f" - {e}" for e in errors)
84
+ )
85
+
86
+ return manifest
87
+
88
+
89
+ def _split_frontmatter(content: str) -> tuple[str, str]:
90
+ """Split SKILL.md content into frontmatter string and body.
91
+
92
+ Expects the file to start with --- on its own line, followed by
93
+ YAML content, then another --- on its own line, then the body.
94
+ Uses line-based matching so that --- in the body (e.g. markdown
95
+ horizontal rules) does not break parsing.
96
+ """
97
+ lines = content.split("\n")
98
+
99
+ # Skip leading blank lines
100
+ start = 0
101
+ while start < len(lines) and lines[start].strip() == "":
102
+ start += 1
103
+
104
+ if start >= len(lines) or lines[start].strip() != "---":
105
+ raise ValueError(
106
+ "SKILL.md must start with --- to begin YAML frontmatter."
107
+ )
108
+
109
+ # Find the closing --- delimiter (must be on its own line)
110
+ close = None
111
+ for i in range(start + 1, len(lines)):
112
+ if lines[i].strip() == "---":
113
+ close = i
114
+ break
115
+
116
+ if close is None:
117
+ raise ValueError(
118
+ "SKILL.md must have closing --- delimiter after frontmatter."
119
+ )
120
+
121
+ frontmatter_str = "\n".join(lines[start + 1 : close])
122
+ body = "\n".join(lines[close + 1 :]).strip()
123
+ return frontmatter_str, body
124
+
125
+
126
+ def _parse_runtime(data: dict | None) -> RuntimeConfig | None:
127
+ """Parse the runtime block from frontmatter."""
128
+ if data is None:
129
+ return None
130
+
131
+ if not isinstance(data, dict):
132
+ raise ValueError("'runtime' must be a mapping.")
133
+
134
+ driver = data.get("driver")
135
+ if not driver or not isinstance(driver, str):
136
+ raise ValueError("runtime.driver is required and must be a string.")
137
+
138
+ entrypoint = data.get("entrypoint")
139
+ if not entrypoint or not isinstance(entrypoint, str):
140
+ raise ValueError("runtime.entrypoint is required and must be a string.")
141
+
142
+ lockfile = data.get("lockfile")
143
+ if not lockfile or not isinstance(lockfile, str):
144
+ raise ValueError("runtime.lockfile is required and must be a string.")
145
+
146
+ env_raw = data.get("env", [])
147
+ if not isinstance(env_raw, list):
148
+ raise ValueError("runtime.env must be a list of strings.")
149
+ env = tuple(str(e) for e in env_raw)
150
+
151
+ return RuntimeConfig(
152
+ driver=driver,
153
+ entrypoint=entrypoint,
154
+ lockfile=lockfile,
155
+ env=env,
156
+ )
157
+
158
+
159
+ def _parse_testing(data: dict | None) -> TestingConfig | None:
160
+ """Parse the testing block from frontmatter."""
161
+ if data is None:
162
+ return None
163
+
164
+ if not isinstance(data, dict):
165
+ raise ValueError("'testing' must be a mapping.")
166
+
167
+ cases = data.get("cases")
168
+ if not cases or not isinstance(cases, str):
169
+ raise ValueError("testing.cases is required and must be a string.")
170
+
171
+ agents_raw = data.get("agents", [])
172
+ if not isinstance(agents_raw, list):
173
+ raise ValueError("testing.agents must be a list.")
174
+
175
+ agents = tuple(_parse_agent_target(a) for a in agents_raw)
176
+
177
+ return TestingConfig(cases=cases, agents=agents)
178
+
179
+
180
+ def _parse_agent_target(data: dict) -> AgentTestTarget:
181
+ """Parse a single agent test target from the testing.agents list."""
182
+ if not isinstance(data, dict):
183
+ raise ValueError("Each agent target must be a mapping.")
184
+
185
+ name = data.get("name")
186
+ if not name or not isinstance(name, str):
187
+ raise ValueError("Agent target 'name' is required.")
188
+
189
+ keys_raw = data.get("required_keys", [])
190
+ if not isinstance(keys_raw, list):
191
+ raise ValueError("Agent target 'required_keys' must be a list.")
192
+
193
+ return AgentTestTarget(
194
+ name=name,
195
+ required_keys=tuple(str(k) for k in keys_raw),
196
+ )
197
+
198
+
199
+ def validate_manifest(manifest: SkillManifest) -> list[str]:
200
+ """Validate a parsed manifest. Returns list of error messages (empty = valid)."""
201
+ errors: list[str] = []
202
+
203
+ if not _NAME_PATTERN.match(manifest.name):
204
+ errors.append(
205
+ f"Invalid name '{manifest.name}': must be 1-64 chars, lowercase "
206
+ "alphanumeric + hyphens, no leading/trailing hyphens."
207
+ )
208
+
209
+ if not manifest.description or len(manifest.description) > 1024:
210
+ errors.append("Description must be 1-1024 characters.")
211
+
212
+ if not manifest.body:
213
+ errors.append("Body (system prompt) must not be empty.")
214
+
215
+ if manifest.runtime and manifest.runtime.driver not in ("local/uv",):
216
+ errors.append(
217
+ f"Unsupported runtime driver '{manifest.runtime.driver}'. "
218
+ "Supported: local/uv"
219
+ )
220
+
221
+ return errors
dhub/core/runtime.py ADDED
@@ -0,0 +1,84 @@
1
+ """Local uv runtime domain logic.
2
+
3
+ Provides functions for validating prerequisites, building commands,
4
+ and preparing environment variables for running skills via uv.
5
+ """
6
+
7
+ import os
8
+ import shutil
9
+ from pathlib import Path
10
+
11
+ from dhub.models import RuntimeConfig
12
+
13
+
14
+ def validate_local_runtime_prerequisites(
15
+ skill_dir: Path, config: RuntimeConfig
16
+ ) -> list[str]:
17
+ """Check that all prerequisites for local runtime are met.
18
+
19
+ Returns a list of error messages. An empty list means all prerequisites
20
+ are satisfied.
21
+ """
22
+ errors: list[str] = []
23
+
24
+ if shutil.which("uv") is None:
25
+ errors.append(
26
+ "'uv' is not installed or not on PATH. "
27
+ "Install it from https://docs.astral.sh/uv/"
28
+ )
29
+
30
+ lockfile_path = skill_dir / config.lockfile
31
+ if not lockfile_path.exists():
32
+ errors.append(
33
+ f"Lockfile not found: {lockfile_path}"
34
+ )
35
+
36
+ entrypoint_path = skill_dir / config.entrypoint
37
+ if not entrypoint_path.exists():
38
+ errors.append(
39
+ f"Entrypoint not found: {entrypoint_path}"
40
+ )
41
+
42
+ return errors
43
+
44
+
45
+ def build_uv_sync_command(skill_dir: Path) -> list[str]:
46
+ """Build the uv sync command for installing dependencies in a skill directory."""
47
+ return ["uv", "sync", "--directory", str(skill_dir)]
48
+
49
+
50
+ def build_uv_run_command(
51
+ skill_dir: Path,
52
+ entrypoint: str,
53
+ extra_args: tuple[str, ...] = (),
54
+ ) -> list[str]:
55
+ """Build the uv run command for executing a skill entrypoint."""
56
+ cmd = ["uv", "run", "--directory", str(skill_dir), "python", entrypoint]
57
+ cmd.extend(extra_args)
58
+ return cmd
59
+
60
+
61
+ def build_env_vars(
62
+ config: RuntimeConfig,
63
+ user_env: dict[str, str] | None = None,
64
+ ) -> dict[str, str]:
65
+ """Build environment variables dict from runtime config and user overrides.
66
+
67
+ Starts with the current process environment, overlays any user-provided
68
+ values, and validates that all required env vars (from config.env) are present.
69
+
70
+ Raises:
71
+ ValueError: If any required environment variable is missing.
72
+ """
73
+ env = dict(os.environ)
74
+
75
+ if user_env:
76
+ env.update(user_env)
77
+
78
+ missing = [var for var in config.env if var not in env]
79
+ if missing:
80
+ raise ValueError(
81
+ f"Missing required environment variables: {', '.join(missing)}"
82
+ )
83
+
84
+ return env
@@ -0,0 +1,50 @@
1
+ """Validation functions for skill names and semver versions."""
2
+
3
+ import re
4
+
5
+ _SEMVER_PATTERN = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$")
6
+
7
+ _SKILL_NAME_PATTERN = re.compile(r"^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$")
8
+
9
+
10
+ def validate_semver(version: str) -> str:
11
+ """Validate that a version string follows semver format (major.minor.patch).
12
+
13
+ Args:
14
+ version: The version string to validate.
15
+
16
+ Returns:
17
+ The validated version string.
18
+
19
+ Raises:
20
+ ValueError: If the version does not match semver format.
21
+ """
22
+ if not _SEMVER_PATTERN.match(version):
23
+ raise ValueError(
24
+ f"Invalid semver '{version}': must be in major.minor.patch format "
25
+ "(e.g. '1.0.0')."
26
+ )
27
+ return version
28
+
29
+
30
+ def validate_skill_name(name: str) -> str:
31
+ """Validate a skill name.
32
+
33
+ A valid skill name is 1-64 characters, lowercase alphanumeric plus hyphens,
34
+ with no leading or trailing hyphens.
35
+
36
+ Args:
37
+ name: The skill name to validate.
38
+
39
+ Returns:
40
+ The validated skill name.
41
+
42
+ Raises:
43
+ ValueError: If the name does not match the required format.
44
+ """
45
+ if not _SKILL_NAME_PATTERN.match(name):
46
+ raise ValueError(
47
+ f"Invalid skill name '{name}': must be 1-64 chars, lowercase "
48
+ "alphanumeric + hyphens, no leading/trailing hyphens."
49
+ )
50
+ return name
dhub/models.py ADDED
@@ -0,0 +1,38 @@
1
+ """Client-side domain models as frozen dataclasses."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class SkillManifest:
8
+ """Parsed SKILL.md content."""
9
+ name: str
10
+ description: str
11
+ license: str | None
12
+ compatibility: str | None
13
+ metadata: dict[str, str] | None
14
+ allowed_tools: str | None
15
+ runtime: "RuntimeConfig | None"
16
+ testing: "TestingConfig | None"
17
+ body: str
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class RuntimeConfig:
22
+ driver: str
23
+ entrypoint: str
24
+ lockfile: str
25
+ env: tuple[str, ...]
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class AgentTestTarget:
30
+ name: str
31
+ required_keys: tuple[str, ...]
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class TestingConfig:
36
+ __test__ = False # prevent pytest from trying to collect this dataclass
37
+ cases: str
38
+ agents: tuple[AgentTestTarget, ...]