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/__init__.py +1 -0
- dhub/cli/__init__.py +1 -0
- dhub/cli/app.py +35 -0
- dhub/cli/auth.py +93 -0
- dhub/cli/config.py +74 -0
- dhub/cli/keys.py +96 -0
- dhub/cli/org.py +125 -0
- dhub/cli/registry.py +254 -0
- dhub/cli/runtime.py +87 -0
- dhub/cli/search.py +36 -0
- dhub/core/__init__.py +1 -0
- dhub/core/install.py +159 -0
- dhub/core/manifest.py +221 -0
- dhub/core/runtime.py +84 -0
- dhub/core/validation.py +50 -0
- dhub/models.py +38 -0
- dhub_cli-0.1.0.dist-info/METADATA +78 -0
- dhub_cli-0.1.0.dist-info/RECORD +20 -0
- dhub_cli-0.1.0.dist-info/WHEEL +4 -0
- dhub_cli-0.1.0.dist-info/entry_points.txt +2 -0
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
|
dhub/core/validation.py
ADDED
|
@@ -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, ...]
|