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/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
dhub/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
dhub/cli/app.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Main Typer app with subcommand registration."""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
app = typer.Typer(
|
|
6
|
+
name="dhub",
|
|
7
|
+
help="Decision Hub - The package manager for AI agent skills",
|
|
8
|
+
no_args_is_help=True,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
# Register top-level commands
|
|
12
|
+
from dhub.cli.auth import login_command # noqa: E402
|
|
13
|
+
from dhub.cli.registry import delete_command, install_command, list_command, publish_command # noqa: E402
|
|
14
|
+
from dhub.cli.runtime import run_command # noqa: E402
|
|
15
|
+
from dhub.cli.search import ask_command # noqa: E402
|
|
16
|
+
|
|
17
|
+
app.command("login")(login_command)
|
|
18
|
+
app.command("publish")(publish_command)
|
|
19
|
+
app.command("install")(install_command)
|
|
20
|
+
app.command("list")(list_command)
|
|
21
|
+
app.command("delete")(delete_command)
|
|
22
|
+
app.command("run")(run_command)
|
|
23
|
+
app.command("ask")(ask_command)
|
|
24
|
+
|
|
25
|
+
# Register subcommand groups
|
|
26
|
+
from dhub.cli.keys import keys_app # noqa: E402
|
|
27
|
+
from dhub.cli.org import org_app # noqa: E402
|
|
28
|
+
|
|
29
|
+
app.add_typer(org_app, name="org")
|
|
30
|
+
app.add_typer(keys_app, name="keys")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def run() -> None:
|
|
34
|
+
"""Entry point for the dhub CLI."""
|
|
35
|
+
app()
|
dhub/cli/auth.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Login via GitHub Device Flow."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def login_command(
|
|
14
|
+
api_url: str = typer.Option(None, "--api-url", help="API URL override"),
|
|
15
|
+
) -> None:
|
|
16
|
+
"""Authenticate with Decision Hub via GitHub."""
|
|
17
|
+
from dhub.cli.config import CliConfig, get_api_url, save_config
|
|
18
|
+
|
|
19
|
+
base_url = api_url or get_api_url()
|
|
20
|
+
|
|
21
|
+
# Step 1: Request a device code from the API
|
|
22
|
+
with httpx.Client() as client:
|
|
23
|
+
resp = client.post(f"{base_url}/auth/github/code")
|
|
24
|
+
resp.raise_for_status()
|
|
25
|
+
data = resp.json()
|
|
26
|
+
|
|
27
|
+
device_code: str = data["device_code"]
|
|
28
|
+
user_code: str = data["user_code"]
|
|
29
|
+
verification_uri: str = data["verification_uri"]
|
|
30
|
+
poll_interval: int = data.get("interval", 5)
|
|
31
|
+
|
|
32
|
+
# Step 2: Show the user code and URL
|
|
33
|
+
console.print(
|
|
34
|
+
Panel(
|
|
35
|
+
f"Open [bold blue]{verification_uri}[/] and enter code: "
|
|
36
|
+
f"[bold green]{user_code}[/]",
|
|
37
|
+
title="GitHub Login",
|
|
38
|
+
)
|
|
39
|
+
)
|
|
40
|
+
console.print("Waiting for authorization...")
|
|
41
|
+
|
|
42
|
+
# Step 3: Poll for the token until the user completes the flow
|
|
43
|
+
token_data = _poll_for_token(base_url, device_code, poll_interval)
|
|
44
|
+
|
|
45
|
+
# Step 4: Persist the token
|
|
46
|
+
new_config = CliConfig(api_url=base_url, token=token_data["access_token"])
|
|
47
|
+
save_config(new_config)
|
|
48
|
+
|
|
49
|
+
console.print(f"[green]Authenticated as @{token_data['username']}[/]")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _poll_for_token(
|
|
53
|
+
base_url: str,
|
|
54
|
+
device_code: str,
|
|
55
|
+
interval: int,
|
|
56
|
+
timeout_seconds: int = 300,
|
|
57
|
+
) -> dict:
|
|
58
|
+
"""Poll the token endpoint until authorization succeeds or times out.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
base_url: API base URL.
|
|
62
|
+
device_code: The device code returned from the code request.
|
|
63
|
+
interval: Seconds to wait between poll attempts.
|
|
64
|
+
timeout_seconds: Maximum total seconds to wait before giving up.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Parsed JSON response containing 'access_token' and 'username'.
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
typer.Exit: If the flow times out or the server rejects the request.
|
|
71
|
+
"""
|
|
72
|
+
deadline = time.monotonic() + timeout_seconds
|
|
73
|
+
|
|
74
|
+
with httpx.Client(timeout=30) as client:
|
|
75
|
+
while time.monotonic() < deadline:
|
|
76
|
+
resp = client.post(
|
|
77
|
+
f"{base_url}/auth/github/token",
|
|
78
|
+
json={"device_code": device_code},
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
if resp.status_code == 200:
|
|
82
|
+
return resp.json()
|
|
83
|
+
|
|
84
|
+
# 428 means "authorization_pending" -- keep polling
|
|
85
|
+
if resp.status_code == 428:
|
|
86
|
+
time.sleep(interval)
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
# Any other error is fatal
|
|
90
|
+
resp.raise_for_status()
|
|
91
|
+
|
|
92
|
+
console.print("[red]Error: Login timed out. Please try again.[/]")
|
|
93
|
+
raise typer.Exit(1)
|
dhub/cli/config.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""CLI configuration file management for ~/.dhub/config.json."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import asdict, dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
CONFIG_DIR = Path.home() / ".dhub"
|
|
11
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
12
|
+
DEFAULT_API_URL = "https://decision-hub--api.modal.run"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class CliConfig:
|
|
17
|
+
"""Immutable CLI configuration."""
|
|
18
|
+
|
|
19
|
+
api_url: str = DEFAULT_API_URL
|
|
20
|
+
token: str | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_config() -> CliConfig:
|
|
24
|
+
"""Load CLI config from ~/.dhub/config.json.
|
|
25
|
+
|
|
26
|
+
Returns defaults if the file does not exist or contains
|
|
27
|
+
incomplete data.
|
|
28
|
+
"""
|
|
29
|
+
if not CONFIG_FILE.exists():
|
|
30
|
+
return CliConfig()
|
|
31
|
+
|
|
32
|
+
raw = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
|
|
33
|
+
return CliConfig(
|
|
34
|
+
api_url=raw.get("api_url", DEFAULT_API_URL),
|
|
35
|
+
token=raw.get("token"),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def save_config(config: CliConfig) -> None:
|
|
40
|
+
"""Save CLI config to ~/.dhub/config.json.
|
|
41
|
+
|
|
42
|
+
Creates the ~/.dhub directory if it does not already exist.
|
|
43
|
+
"""
|
|
44
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
CONFIG_FILE.write_text(
|
|
46
|
+
json.dumps(asdict(config), indent=2) + "\n",
|
|
47
|
+
encoding="utf-8",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_api_url() -> str:
|
|
52
|
+
"""Get API URL from the DHUB_API_URL env var, falling back to saved config."""
|
|
53
|
+
env_url = os.environ.get("DHUB_API_URL")
|
|
54
|
+
if env_url:
|
|
55
|
+
return env_url
|
|
56
|
+
return load_config().api_url
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def get_token() -> str:
|
|
60
|
+
"""Get the stored auth token.
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
typer.Exit: If no token is stored (user not logged in).
|
|
64
|
+
"""
|
|
65
|
+
from rich.console import Console
|
|
66
|
+
|
|
67
|
+
token = load_config().token
|
|
68
|
+
if not token:
|
|
69
|
+
console = Console(stderr=True)
|
|
70
|
+
console.print(
|
|
71
|
+
"[red]Error: Not logged in. Run [bold]dhub login[/bold] first.[/]"
|
|
72
|
+
)
|
|
73
|
+
raise typer.Exit(1)
|
|
74
|
+
return token
|
dhub/cli/keys.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""API key management commands."""
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
import typer
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
|
|
8
|
+
console = Console()
|
|
9
|
+
keys_app = typer.Typer(help="Manage API keys for agent evals", no_args_is_help=True)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _headers() -> dict[str, str]:
|
|
13
|
+
"""Build authorization headers using the stored token."""
|
|
14
|
+
from dhub.cli.config import get_token
|
|
15
|
+
|
|
16
|
+
return {"Authorization": f"Bearer {get_token()}"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _api_url() -> str:
|
|
20
|
+
"""Retrieve the configured API URL."""
|
|
21
|
+
from dhub.cli.config import get_api_url
|
|
22
|
+
|
|
23
|
+
return get_api_url()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@keys_app.command("add")
|
|
27
|
+
def add_key(
|
|
28
|
+
key_name: str = typer.Argument(help="Name for the API key"),
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Add an API key (prompts for the value securely)."""
|
|
31
|
+
key_value = typer.prompt("Enter API key value", hide_input=True)
|
|
32
|
+
|
|
33
|
+
if not key_value.strip():
|
|
34
|
+
console.print("[red]Error: Key value cannot be empty.[/]")
|
|
35
|
+
raise typer.Exit(1)
|
|
36
|
+
|
|
37
|
+
with httpx.Client() as client:
|
|
38
|
+
resp = client.post(
|
|
39
|
+
f"{_api_url()}/v1/keys",
|
|
40
|
+
headers=_headers(),
|
|
41
|
+
json={"key_name": key_name, "value": key_value},
|
|
42
|
+
)
|
|
43
|
+
if resp.status_code == 409:
|
|
44
|
+
console.print(
|
|
45
|
+
f"[red]Error: Key '{key_name}' already exists. "
|
|
46
|
+
"Remove it first with [bold]dhub keys remove[/bold].[/]"
|
|
47
|
+
)
|
|
48
|
+
raise typer.Exit(1)
|
|
49
|
+
resp.raise_for_status()
|
|
50
|
+
|
|
51
|
+
console.print(f"[green]Added key: {key_name}[/]")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@keys_app.command("list")
|
|
55
|
+
def list_keys() -> None:
|
|
56
|
+
"""List stored API key names."""
|
|
57
|
+
with httpx.Client() as client:
|
|
58
|
+
resp = client.get(
|
|
59
|
+
f"{_api_url()}/v1/keys",
|
|
60
|
+
headers=_headers(),
|
|
61
|
+
)
|
|
62
|
+
resp.raise_for_status()
|
|
63
|
+
keys = resp.json()
|
|
64
|
+
|
|
65
|
+
if not keys:
|
|
66
|
+
console.print("No API keys stored.")
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
table = Table(title="API Keys")
|
|
70
|
+
table.add_column("Name", style="cyan")
|
|
71
|
+
table.add_column("Created", style="dim")
|
|
72
|
+
|
|
73
|
+
for key in keys:
|
|
74
|
+
table.add_row(key.get("key_name", ""), key.get("created_at", ""))
|
|
75
|
+
|
|
76
|
+
console.print(table)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@keys_app.command("remove")
|
|
80
|
+
def remove_key(
|
|
81
|
+
key_name: str = typer.Argument(help="Name of the API key to remove"),
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Remove a stored API key."""
|
|
84
|
+
with httpx.Client() as client:
|
|
85
|
+
resp = client.delete(
|
|
86
|
+
f"{_api_url()}/v1/keys/{key_name}",
|
|
87
|
+
headers=_headers(),
|
|
88
|
+
)
|
|
89
|
+
if resp.status_code == 404:
|
|
90
|
+
console.print(
|
|
91
|
+
f"[red]Error: Key '{key_name}' not found.[/]"
|
|
92
|
+
)
|
|
93
|
+
raise typer.Exit(1)
|
|
94
|
+
resp.raise_for_status()
|
|
95
|
+
|
|
96
|
+
console.print(f"[green]Removed key: {key_name}[/]")
|
dhub/cli/org.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Organization management commands."""
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
import typer
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
|
|
8
|
+
console = Console()
|
|
9
|
+
org_app = typer.Typer(help="Manage organizations", no_args_is_help=True)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _headers() -> dict[str, str]:
|
|
13
|
+
"""Build authorization headers using the stored token."""
|
|
14
|
+
from dhub.cli.config import get_token
|
|
15
|
+
|
|
16
|
+
return {"Authorization": f"Bearer {get_token()}"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _api_url() -> str:
|
|
20
|
+
"""Retrieve the configured API URL."""
|
|
21
|
+
from dhub.cli.config import get_api_url
|
|
22
|
+
|
|
23
|
+
return get_api_url()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@org_app.command("create")
|
|
27
|
+
def create_org(slug: str = typer.Argument(help="Organization slug")) -> None:
|
|
28
|
+
"""Create a new organization."""
|
|
29
|
+
with httpx.Client() as client:
|
|
30
|
+
resp = client.post(
|
|
31
|
+
f"{_api_url()}/v1/orgs",
|
|
32
|
+
headers=_headers(),
|
|
33
|
+
json={"slug": slug},
|
|
34
|
+
)
|
|
35
|
+
if resp.status_code == 409:
|
|
36
|
+
console.print(
|
|
37
|
+
f"[red]Error: Organization '{slug}' already exists.[/]"
|
|
38
|
+
)
|
|
39
|
+
raise typer.Exit(1)
|
|
40
|
+
resp.raise_for_status()
|
|
41
|
+
data = resp.json()
|
|
42
|
+
|
|
43
|
+
console.print(f"[green]Created organization: {data['slug']}[/]")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@org_app.command("list")
|
|
47
|
+
def list_orgs() -> None:
|
|
48
|
+
"""List organizations you belong to."""
|
|
49
|
+
with httpx.Client() as client:
|
|
50
|
+
resp = client.get(
|
|
51
|
+
f"{_api_url()}/v1/orgs",
|
|
52
|
+
headers=_headers(),
|
|
53
|
+
)
|
|
54
|
+
resp.raise_for_status()
|
|
55
|
+
orgs = resp.json()
|
|
56
|
+
|
|
57
|
+
if not orgs:
|
|
58
|
+
console.print("You are not a member of any organizations.")
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
table = Table(title="Organizations")
|
|
62
|
+
table.add_column("Slug", style="cyan")
|
|
63
|
+
table.add_column("Role", style="green")
|
|
64
|
+
|
|
65
|
+
for org in orgs:
|
|
66
|
+
table.add_row(org.get("slug", ""), org.get("role", ""))
|
|
67
|
+
|
|
68
|
+
console.print(table)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@org_app.command("invite")
|
|
72
|
+
def invite_member(
|
|
73
|
+
org: str = typer.Argument(help="Organization slug"),
|
|
74
|
+
user: str = typer.Option(..., "--user", help="GitHub username to invite"),
|
|
75
|
+
role: str = typer.Option(
|
|
76
|
+
"member", "--role", help="Role: owner, admin, or member"
|
|
77
|
+
),
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Invite a user to an organization."""
|
|
80
|
+
with httpx.Client() as client:
|
|
81
|
+
resp = client.post(
|
|
82
|
+
f"{_api_url()}/v1/orgs/{org}/invites",
|
|
83
|
+
headers=_headers(),
|
|
84
|
+
json={"invitee_github_username": user, "role": role},
|
|
85
|
+
)
|
|
86
|
+
if resp.status_code == 404:
|
|
87
|
+
console.print(
|
|
88
|
+
f"[red]Error: Organization '{org}' not found.[/]"
|
|
89
|
+
)
|
|
90
|
+
raise typer.Exit(1)
|
|
91
|
+
if resp.status_code == 403:
|
|
92
|
+
console.print(
|
|
93
|
+
"[red]Error: You do not have permission to invite members.[/]"
|
|
94
|
+
)
|
|
95
|
+
raise typer.Exit(1)
|
|
96
|
+
resp.raise_for_status()
|
|
97
|
+
data = resp.json()
|
|
98
|
+
|
|
99
|
+
console.print(
|
|
100
|
+
f"[green]Invited @{user} to '{org}' as {role}. "
|
|
101
|
+
f"Invite ID: {data['id']}[/]"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@org_app.command("accept")
|
|
106
|
+
def accept_invite(
|
|
107
|
+
invite_id: str = typer.Argument(help="Invite ID to accept"),
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Accept an organization invite."""
|
|
110
|
+
with httpx.Client() as client:
|
|
111
|
+
resp = client.post(
|
|
112
|
+
f"{_api_url()}/v1/invites/{invite_id}/accept",
|
|
113
|
+
headers=_headers(),
|
|
114
|
+
)
|
|
115
|
+
if resp.status_code == 404:
|
|
116
|
+
console.print(
|
|
117
|
+
f"[red]Error: Invite '{invite_id}' not found.[/]"
|
|
118
|
+
)
|
|
119
|
+
raise typer.Exit(1)
|
|
120
|
+
resp.raise_for_status()
|
|
121
|
+
data = resp.json()
|
|
122
|
+
|
|
123
|
+
console.print(
|
|
124
|
+
f"[green]Accepted invite. You are now a member of '{data['org_slug']}'.[/]"
|
|
125
|
+
)
|
dhub/cli/registry.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Publish, install, list, and delete commands for the skill registry."""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
import zipfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def publish_command(
|
|
17
|
+
path: Path = typer.Argument(
|
|
18
|
+
Path("."), help="Path to the skill directory"
|
|
19
|
+
),
|
|
20
|
+
org: str = typer.Option(..., "--org", help="Organization slug"),
|
|
21
|
+
name: str = typer.Option(..., "--name", help="Skill name"),
|
|
22
|
+
version: str = typer.Option(..., "--version", help="Semver version"),
|
|
23
|
+
) -> None:
|
|
24
|
+
"""Publish a skill to the registry."""
|
|
25
|
+
from dhub.cli.config import get_api_url, get_token
|
|
26
|
+
from dhub.core.validation import validate_semver, validate_skill_name
|
|
27
|
+
|
|
28
|
+
# Validate inputs before doing any I/O
|
|
29
|
+
validate_skill_name(name)
|
|
30
|
+
validate_semver(version)
|
|
31
|
+
|
|
32
|
+
# Verify the directory contains a SKILL.md manifest
|
|
33
|
+
skill_md = path / "SKILL.md"
|
|
34
|
+
if not skill_md.exists():
|
|
35
|
+
console.print(
|
|
36
|
+
"[red]Error: SKILL.md not found in the specified directory.[/]"
|
|
37
|
+
)
|
|
38
|
+
raise typer.Exit(1)
|
|
39
|
+
|
|
40
|
+
# Package the directory into a zip archive
|
|
41
|
+
console.print(f"Packaging skill from [cyan]{path.resolve()}[/]...")
|
|
42
|
+
zip_data = _create_zip(path)
|
|
43
|
+
|
|
44
|
+
# Upload to the registry
|
|
45
|
+
console.print("Uploading...")
|
|
46
|
+
metadata = json.dumps(
|
|
47
|
+
{"org_slug": org, "skill_name": name, "version": version}
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
with httpx.Client(timeout=60) as client:
|
|
51
|
+
resp = client.post(
|
|
52
|
+
f"{get_api_url()}/v1/publish",
|
|
53
|
+
headers={"Authorization": f"Bearer {get_token()}"},
|
|
54
|
+
files={"zip_file": ("skill.zip", zip_data, "application/zip")},
|
|
55
|
+
data={"metadata": metadata},
|
|
56
|
+
)
|
|
57
|
+
if resp.status_code == 409:
|
|
58
|
+
console.print(
|
|
59
|
+
f"[red]Error: Version {version} already exists for "
|
|
60
|
+
f"{org}/{name}.[/]"
|
|
61
|
+
)
|
|
62
|
+
raise typer.Exit(1)
|
|
63
|
+
resp.raise_for_status()
|
|
64
|
+
|
|
65
|
+
console.print(f"[green]Published: {org}/{name}@{version}[/]")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _create_zip(path: Path) -> bytes:
|
|
69
|
+
"""Create an in-memory zip archive of a directory.
|
|
70
|
+
|
|
71
|
+
Skips hidden files (names starting with '.') and __pycache__
|
|
72
|
+
directories.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
path: Root directory to archive.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Raw bytes of the zip file.
|
|
79
|
+
"""
|
|
80
|
+
buf = io.BytesIO()
|
|
81
|
+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
82
|
+
for file in sorted(path.rglob("*")):
|
|
83
|
+
if not file.is_file():
|
|
84
|
+
continue
|
|
85
|
+
# Skip hidden files and __pycache__
|
|
86
|
+
relative = file.relative_to(path)
|
|
87
|
+
parts = relative.parts
|
|
88
|
+
if any(part.startswith(".") or part == "__pycache__" for part in parts):
|
|
89
|
+
continue
|
|
90
|
+
zf.write(file, relative)
|
|
91
|
+
return buf.getvalue()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def list_command() -> None:
|
|
95
|
+
"""List all published skills on the registry."""
|
|
96
|
+
from dhub.cli.config import get_api_url
|
|
97
|
+
|
|
98
|
+
api_url = get_api_url()
|
|
99
|
+
|
|
100
|
+
with httpx.Client(timeout=30) as client:
|
|
101
|
+
resp = client.get(f"{api_url}/v1/skills")
|
|
102
|
+
resp.raise_for_status()
|
|
103
|
+
skills = resp.json()
|
|
104
|
+
|
|
105
|
+
console.print(f"Registry: [dim]{api_url}[/]")
|
|
106
|
+
|
|
107
|
+
if not skills:
|
|
108
|
+
console.print("No skills published yet.")
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
table = Table(title="Published Skills")
|
|
112
|
+
table.add_column("Org", style="cyan")
|
|
113
|
+
table.add_column("Skill", style="green")
|
|
114
|
+
table.add_column("Version")
|
|
115
|
+
table.add_column("Updated")
|
|
116
|
+
table.add_column("Safety")
|
|
117
|
+
table.add_column("Author")
|
|
118
|
+
table.add_column("Description")
|
|
119
|
+
|
|
120
|
+
for s in skills:
|
|
121
|
+
table.add_row(
|
|
122
|
+
s["org_slug"],
|
|
123
|
+
s["skill_name"],
|
|
124
|
+
s["latest_version"],
|
|
125
|
+
s.get("updated_at", ""),
|
|
126
|
+
s.get("safety_rating", ""),
|
|
127
|
+
s.get("author", ""),
|
|
128
|
+
s.get("description", ""),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
console.print(table)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def delete_command(
|
|
135
|
+
skill_ref: str = typer.Argument(help="Skill reference: org/skill"),
|
|
136
|
+
version: str = typer.Option(..., "--version", "-v", help="Version to delete"),
|
|
137
|
+
) -> None:
|
|
138
|
+
"""Delete a published skill version from the registry."""
|
|
139
|
+
from dhub.cli.config import get_api_url, get_token
|
|
140
|
+
|
|
141
|
+
parts = skill_ref.split("/", 1)
|
|
142
|
+
if len(parts) != 2:
|
|
143
|
+
console.print(
|
|
144
|
+
"[red]Error: Skill reference must be in org/skill format.[/]"
|
|
145
|
+
)
|
|
146
|
+
raise typer.Exit(1)
|
|
147
|
+
org_slug, skill_name = parts
|
|
148
|
+
|
|
149
|
+
with httpx.Client(timeout=60) as client:
|
|
150
|
+
resp = client.delete(
|
|
151
|
+
f"{get_api_url()}/v1/skills/{org_slug}/{skill_name}/{version}",
|
|
152
|
+
headers={"Authorization": f"Bearer {get_token()}"},
|
|
153
|
+
)
|
|
154
|
+
if resp.status_code == 404:
|
|
155
|
+
console.print(
|
|
156
|
+
f"[red]Error: Version {version} not found for "
|
|
157
|
+
f"{org_slug}/{skill_name}.[/]"
|
|
158
|
+
)
|
|
159
|
+
raise typer.Exit(1)
|
|
160
|
+
if resp.status_code == 403:
|
|
161
|
+
console.print(
|
|
162
|
+
"[red]Error: You don't have permission to delete this version.[/]"
|
|
163
|
+
)
|
|
164
|
+
raise typer.Exit(1)
|
|
165
|
+
resp.raise_for_status()
|
|
166
|
+
|
|
167
|
+
console.print(f"[green]Deleted: {org_slug}/{skill_name}@{version}[/]")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def install_command(
|
|
171
|
+
skill_ref: str = typer.Argument(help="Skill reference: org/skill"),
|
|
172
|
+
version: str = typer.Option(
|
|
173
|
+
"latest", "--version", "-v", help="Version spec"
|
|
174
|
+
),
|
|
175
|
+
agent: str = typer.Option(
|
|
176
|
+
None, "--agent", help="Target agent (claude, cursor, etc.) or 'all'"
|
|
177
|
+
),
|
|
178
|
+
) -> None:
|
|
179
|
+
"""Install a skill from the registry."""
|
|
180
|
+
from dhub.cli.config import get_api_url, get_token
|
|
181
|
+
from dhub.core.install import (
|
|
182
|
+
get_dhub_skill_path,
|
|
183
|
+
link_skill_to_agent,
|
|
184
|
+
link_skill_to_all_agents,
|
|
185
|
+
verify_checksum,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Parse skill reference
|
|
189
|
+
parts = skill_ref.split("/", 1)
|
|
190
|
+
if len(parts) != 2:
|
|
191
|
+
console.print(
|
|
192
|
+
"[red]Error: Skill reference must be in org/skill format.[/]"
|
|
193
|
+
)
|
|
194
|
+
raise typer.Exit(1)
|
|
195
|
+
org_slug, skill_name = parts
|
|
196
|
+
|
|
197
|
+
headers = {"Authorization": f"Bearer {get_token()}"}
|
|
198
|
+
base_url = get_api_url()
|
|
199
|
+
|
|
200
|
+
# Resolve the version to a concrete download URL and checksum
|
|
201
|
+
console.print(f"Resolving {org_slug}/{skill_name}@{version}...")
|
|
202
|
+
with httpx.Client() as client:
|
|
203
|
+
resp = client.get(
|
|
204
|
+
f"{base_url}/v1/resolve/{org_slug}/{skill_name}",
|
|
205
|
+
params={"spec": version},
|
|
206
|
+
headers=headers,
|
|
207
|
+
)
|
|
208
|
+
if resp.status_code == 404:
|
|
209
|
+
console.print(
|
|
210
|
+
f"[red]Error: Skill '{skill_ref}' not found.[/]"
|
|
211
|
+
)
|
|
212
|
+
raise typer.Exit(1)
|
|
213
|
+
resp.raise_for_status()
|
|
214
|
+
data = resp.json()
|
|
215
|
+
|
|
216
|
+
resolved_version: str = data["version"]
|
|
217
|
+
download_url: str = data["download_url"]
|
|
218
|
+
expected_checksum: str = data["checksum"]
|
|
219
|
+
|
|
220
|
+
# Download the zip
|
|
221
|
+
console.print(f"Downloading {org_slug}/{skill_name}@{resolved_version}...")
|
|
222
|
+
with httpx.Client() as client:
|
|
223
|
+
resp = client.get(download_url)
|
|
224
|
+
resp.raise_for_status()
|
|
225
|
+
zip_data = resp.content
|
|
226
|
+
|
|
227
|
+
# Verify integrity
|
|
228
|
+
console.print("Verifying checksum...")
|
|
229
|
+
verify_checksum(zip_data, expected_checksum)
|
|
230
|
+
|
|
231
|
+
# Extract to the canonical skill path
|
|
232
|
+
skill_path = get_dhub_skill_path(org_slug, skill_name)
|
|
233
|
+
skill_path.mkdir(parents=True, exist_ok=True)
|
|
234
|
+
|
|
235
|
+
with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
236
|
+
zf.extractall(skill_path)
|
|
237
|
+
|
|
238
|
+
console.print(
|
|
239
|
+
f"[green]Installed {org_slug}/{skill_name}@{resolved_version} "
|
|
240
|
+
f"to {skill_path}[/]"
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
# Create agent symlinks
|
|
244
|
+
if agent:
|
|
245
|
+
if agent == "all":
|
|
246
|
+
linked = link_skill_to_all_agents(org_slug, skill_name)
|
|
247
|
+
console.print(
|
|
248
|
+
f"[green]Linked to agents: {', '.join(linked)}[/]"
|
|
249
|
+
)
|
|
250
|
+
else:
|
|
251
|
+
link_path = link_skill_to_agent(org_slug, skill_name, agent)
|
|
252
|
+
console.print(
|
|
253
|
+
f"[green]Linked to {agent} at {link_path}[/]"
|
|
254
|
+
)
|