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 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
+ )