duhub-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.
du_cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """duhub CLI — external developer client for DUhub."""
du_cli/client.py ADDED
@@ -0,0 +1,65 @@
1
+ """Single httpx wrapper: auth headers, error hints, timeout."""
2
+ from __future__ import annotations
3
+
4
+ import contextlib
5
+
6
+ import httpx
7
+
8
+ TIMEOUT = 300.0
9
+ CONNECT_TIMEOUT = 10.0
10
+ MAX_UPLOAD_BYTES = 30 * 1024 * 1024
11
+
12
+ class CliError(Exception):
13
+ def __init__(self, status: int, detail: str, hint: str):
14
+ super().__init__(detail)
15
+ self.status = status
16
+ self.detail = detail
17
+ self.hint = hint
18
+
19
+ def make_client() -> httpx.Client:
20
+ # Long read timeout for audio jobs, short connect timeout for bad hosts.
21
+ return httpx.Client(timeout=httpx.Timeout(TIMEOUT, connect=CONNECT_TIMEOUT))
22
+
23
+ @contextlib.contextmanager
24
+ def api_client():
25
+ """Yield an httpx client; transport failures become CliError (no tracebacks)."""
26
+ try:
27
+ with make_client() as c:
28
+ yield c
29
+ except httpx.RequestError as e:
30
+ raise CliError(
31
+ 502,
32
+ f"cannot reach server: {e}",
33
+ "check base URL and try `duhub health --detailed`",
34
+ ) from e
35
+
36
+ def auth_headers(api_key: str, idempotency_key: str | None = None) -> dict[str, str]:
37
+ h = {"X-API-Key": api_key}
38
+ if idempotency_key:
39
+ h["Idempotency-Key"] = idempotency_key
40
+ return h
41
+
42
+ def error_hint(status: int, detail: str, path: str = "") -> str:
43
+ d = (detail or "").lower()
44
+ if status == 401:
45
+ return "invalid key — run `duhub configure`, copy from dashboard API Keys"
46
+ if status == 402 or "insufficient_balance" in d:
47
+ return "insufficient balance — top-up via dashboard Wallet"
48
+ if status == 403:
49
+ return "organization pending review — contact org admin"
50
+ if status == 413:
51
+ return "file exceeds 30 MB limit — compress or split audio"
52
+ if status == 503:
53
+ return "server busy (saturated) — retry after Retry-After seconds"
54
+ if status == 502:
55
+ return "backend unreachable — try `duhub health --detailed`"
56
+ if status == 404:
57
+ return "not found — check base URL and gateway version"
58
+ return detail or f"HTTP {status}"
59
+
60
+ def raise_for_api(status: int, body: str, path: str = "") -> None:
61
+ if 200 <= status < 300:
62
+ return
63
+ detail = body[:500] if body else f"HTTP {status}"
64
+ # try to keep gateway JSON detail readable
65
+ raise CliError(status, detail, error_hint(status, detail, path))
du_cli/config.py ADDED
@@ -0,0 +1,67 @@
1
+ """Config load/save: env > ~/.config/du/config.toml > defaults."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import tomllib
6
+ from pathlib import Path
7
+
8
+ DEFAULT_BASE_URL = "https://du-api.dev.umuganda.digital"
9
+ CONFIG_PATH = Path.home() / ".config" / "du" / "config.toml"
10
+
11
+ def _read_file(path: Path) -> dict[str, str]:
12
+ try:
13
+ with path.open("rb") as fh:
14
+ data = tomllib.load(fh)
15
+ except (FileNotFoundError, tomllib.TOMLDecodeError, UnicodeDecodeError, OSError):
16
+ return {}
17
+ out: dict[str, str] = {}
18
+ if isinstance(data.get("base_url"), str):
19
+ out["base_url"] = data["base_url"]
20
+ if isinstance(data.get("api_key"), str):
21
+ out["api_key"] = data["api_key"]
22
+ return out
23
+
24
+ def load_config(base_url_override: str | None = None, api_key_override: str | None = None) -> dict[str, str]:
25
+ filed = _read_file(CONFIG_PATH)
26
+ base = base_url_override if base_url_override is not None else os.environ.get("DU_BASE_URL", filed.get("base_url", DEFAULT_BASE_URL))
27
+ key = api_key_override if api_key_override is not None else os.environ.get("DU_API_KEY", filed.get("api_key", ""))
28
+ return {"base_url": base.strip().rstrip("/"), "api_key": key.strip()}
29
+
30
+ def _q(v: str) -> str:
31
+ escaped = (
32
+ v.replace("\\", "\\\\")
33
+ .replace('"', '\\"')
34
+ .replace("\n", "\\n")
35
+ .replace("\r", "\\r")
36
+ .replace("\t", "\\t")
37
+ )
38
+ return '"' + escaped + '"'
39
+
40
+ def save_config(base_url: str | None, api_key: str | None) -> Path:
41
+ CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
42
+ prev = _read_file(CONFIG_PATH)
43
+ if base_url is not None:
44
+ prev["base_url"] = base_url.strip().rstrip("/")
45
+ if api_key is not None:
46
+ prev["api_key"] = api_key.strip()
47
+ lines = []
48
+ if "base_url" in prev:
49
+ lines.append(f"base_url = {_q(prev['base_url'])}")
50
+ if "api_key" in prev:
51
+ lines.append(f"api_key = {_q(prev['api_key'])}")
52
+ text = "\n".join(lines) + ("\n" if lines else "")
53
+ fd = os.open(CONFIG_PATH, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
54
+ if hasattr(os, "fchmod"):
55
+ try:
56
+ os.fchmod(fd, 0o600) # tighten before the key is written
57
+ except OSError:
58
+ pass
59
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
60
+ fh.write(text)
61
+ return CONFIG_PATH
62
+
63
+ def clear_config() -> None:
64
+ try:
65
+ CONFIG_PATH.unlink()
66
+ except FileNotFoundError:
67
+ pass
du_cli/main.py ADDED
@@ -0,0 +1,269 @@
1
+ """duhub CLI entrypoint: configure, health, stt, tts, mt, diarize."""
2
+ from __future__ import annotations
3
+
4
+ import contextlib
5
+ from pathlib import Path as _Path
6
+
7
+ import typer
8
+
9
+ from du_cli import config, output
10
+ from du_cli.client import (
11
+ MAX_UPLOAD_BYTES,
12
+ CliError,
13
+ api_client,
14
+ auth_headers,
15
+ raise_for_api,
16
+ )
17
+ from du_cli.output import render_segments as _render_segments
18
+
19
+ __version__ = "0.1.0"
20
+
21
+ def _version_callback(value: bool) -> None:
22
+ if value:
23
+ typer.echo(f"duhub {__version__}")
24
+ raise typer.Exit()
25
+
26
+ app = typer.Typer(
27
+ no_args_is_help=True,
28
+ add_completion=False,
29
+ help="DUhub CLI — STT, TTS, MT and diarization over your API key.",
30
+ epilog="Keys live on the dashboard under API Keys; billing per second (audio) or word (text).",
31
+ )
32
+
33
+ @app.callback()
34
+ def _root(
35
+ version: bool = typer.Option(
36
+ False, "--version", callback=_version_callback, is_eager=True,
37
+ help="Show version and exit.",
38
+ ),
39
+ ) -> None:
40
+ pass
41
+
42
+ def _cfg(base_url: str | None, api_key: str | None) -> dict[str, str]:
43
+ return config.load_config(base_url, api_key)
44
+
45
+ @contextlib.contextmanager
46
+ def _api(cfg: dict[str, str], json_out: bool):
47
+ """Authed client; missing key, transport, or HTTP errors become exit(1)."""
48
+ try:
49
+ if not cfg["api_key"]:
50
+ raise CliError(401, "missing API key", "run `duhub configure --api-key KEY`")
51
+ with api_client() as c:
52
+ yield c
53
+ except CliError as e:
54
+ output.print_err(e.detail, e.hint, json_out)
55
+ raise typer.Exit(1)
56
+
57
+ def _audio_path(file: str, json_out: bool) -> _Path:
58
+ """Validate an upload path; map filesystem errors to a clean CLI error."""
59
+ p = _Path(file)
60
+ try:
61
+ if not p.exists():
62
+ output.print_err(f"file not found: {file}", "check the path", json_out)
63
+ raise typer.Exit(1)
64
+ if not p.is_file():
65
+ output.print_err(f"not a file: {file}", "pass an audio file path", json_out)
66
+ raise typer.Exit(1)
67
+ if p.stat().st_size > MAX_UPLOAD_BYTES:
68
+ output.print_err("file exceeds 30 MB", "compress or split audio", json_out)
69
+ raise typer.Exit(1)
70
+ except OSError as e:
71
+ output.print_err(f"cannot read {file}: {e}", "check the path and permissions", json_out)
72
+ raise typer.Exit(1)
73
+ return p
74
+
75
+ def _open_audio(p: _Path, json_out: bool):
76
+ """Open an upload file, turning permission errors into a clean CLI error."""
77
+ try:
78
+ return p.open("rb")
79
+ except OSError as e:
80
+ output.print_err(f"cannot read {p}: {e}", "check the path and permissions", json_out)
81
+ raise typer.Exit(1)
82
+
83
+ def _check_output(o: str | None, json_out: bool) -> None:
84
+ if o:
85
+ output.check_output_path(o, json_out)
86
+
87
+ @app.command()
88
+ def configure(
89
+ base_url: str | None = typer.Option(None, "--base-url"),
90
+ api_key: str | None = typer.Option(None, "--api-key"),
91
+ reset: bool = typer.Option(False, "--reset"),
92
+ ) -> None:
93
+ if reset:
94
+ try:
95
+ config.clear_config()
96
+ except OSError as e:
97
+ output.print_err(f"cannot remove {config.CONFIG_PATH}: {e}", "check permissions", False)
98
+ raise typer.Exit(1)
99
+ typer.echo("cleared")
100
+ return
101
+ if not base_url and not api_key:
102
+ cur = config.load_config()
103
+ typer.echo(f"base_url={cur['base_url']}")
104
+ typer.echo("api_key=" + ("set" if cur["api_key"] else "missing"))
105
+ return
106
+ try:
107
+ p = config.save_config(base_url, api_key)
108
+ except OSError as e:
109
+ output.print_err(f"cannot write {config.CONFIG_PATH}: {e}", "check permissions", False)
110
+ raise typer.Exit(1)
111
+ typer.echo(f"saved {p}")
112
+
113
+ @app.command()
114
+ def health(
115
+ detailed: bool = typer.Option(False, "--detailed"),
116
+ json_out: bool = typer.Option(False, "--json", help="Machine-readable single-JSON output."),
117
+ base_url: str | None = typer.Option(None, "--base-url"),
118
+ api_key: str | None = typer.Option(None, "--api-key"),
119
+ ) -> None:
120
+ cfg = _cfg(base_url, api_key)
121
+ path = "/health/detailed" if detailed else "/health"
122
+ try:
123
+ headers = auth_headers(cfg["api_key"]) if cfg["api_key"] else {}
124
+ with api_client() as c:
125
+ r = c.get(cfg["base_url"] + path, headers=headers)
126
+ raise_for_api(r.status_code, r.text, path)
127
+ output.present(
128
+ r.text,
129
+ {"units": "", "cache": "", "request_id": r.headers.get("x-request-id", "")},
130
+ json_out, None,
131
+ )
132
+ except CliError as e:
133
+ output.print_err(e.detail, e.hint, json_out)
134
+ raise typer.Exit(1)
135
+
136
+ @app.command()
137
+ def stt(
138
+ file: str = typer.Argument(..., help="Audio file (wav/flac/ogg, ≤30MB)"),
139
+ o: str | None = typer.Option(None, "-o", "--output"),
140
+ idempotency_key: str | None = typer.Option(None, "--idempotency-key"),
141
+ json_out: bool = typer.Option(False, "--json", help="Machine-readable single-JSON output."),
142
+ ) -> None:
143
+ cfg = _cfg(None, None)
144
+ p = _audio_path(file, json_out)
145
+ _check_output(o, json_out)
146
+ with _api(cfg, json_out) as c, _open_audio(p, json_out) as fh:
147
+ r = c.post(
148
+ cfg["base_url"] + "/stt",
149
+ headers=auth_headers(cfg["api_key"], idempotency_key),
150
+ files={"file": (p.name, fh)},
151
+ )
152
+ raise_for_api(r.status_code, r.text, "/stt")
153
+ output.present(
154
+ r.text,
155
+ {
156
+ "units": r.headers.get("x-units", ""),
157
+ "cache": r.headers.get("x-cache-status", ""),
158
+ "request_id": r.headers.get("x-request-id", ""),
159
+ },
160
+ json_out, o,
161
+ )
162
+
163
+ @app.command()
164
+ def tts(
165
+ text: str = typer.Argument("", help="Text to synthesize"),
166
+ o: str = typer.Option(..., "-o", "--output", help="Output wav path (required)"),
167
+ text_file: str | None = typer.Option(None, "--text-file"),
168
+ idempotency_key: str | None = typer.Option(None, "--idempotency-key"),
169
+ json_out: bool = typer.Option(False, "--json", help="Machine-readable single-JSON output."),
170
+ ) -> None:
171
+ cfg = _cfg(None, None)
172
+ if text_file:
173
+ try:
174
+ text = _Path(text_file).read_text(encoding="utf-8").strip()
175
+ except (FileNotFoundError, IsADirectoryError, OSError, ValueError) as e:
176
+ output.print_err(f"cannot read --text-file: {e}", "check the path", json_out)
177
+ raise typer.Exit(1)
178
+ if not text.strip():
179
+ output.print_err("empty text", "provide TEXT or --text-file", json_out)
180
+ raise typer.Exit(1)
181
+ _check_output(o, json_out)
182
+ if not json_out:
183
+ typer.echo(f"words={len(text.split())}")
184
+ with _api(cfg, json_out) as c:
185
+ r = c.post(
186
+ cfg["base_url"] + "/tts",
187
+ headers={**auth_headers(cfg["api_key"], idempotency_key), "Content-Type": "application/json"},
188
+ json={"text": text},
189
+ )
190
+ raise_for_api(r.status_code, r.text, "/tts")
191
+ output.save_bytes(o, r.content, json_out)
192
+ output.print_ok({"units": r.headers.get("x-units", ""), "cache": r.headers.get("x-cache-status", ""), "request_id": r.headers.get("x-request-id", "")}, json_out)
193
+
194
+ @app.command()
195
+ def mt(
196
+ text: str = typer.Argument(..., help="Text to translate"),
197
+ src: str = typer.Option("rw", "--src"),
198
+ tgt: str = typer.Option("en", "--tgt"),
199
+ o: str | None = typer.Option(None, "-o", "--output"),
200
+ idempotency_key: str | None = typer.Option(None, "--idempotency-key"),
201
+ json_out: bool = typer.Option(False, "--json", help="Machine-readable single-JSON output."),
202
+ ) -> None:
203
+ cfg = _cfg(None, None)
204
+ if src not in ("rw", "en") or tgt not in ("rw", "en"):
205
+ output.print_err(f"unsupported lang src={src} tgt={tgt}", "use --src rw|en --tgt rw|en", json_out)
206
+ raise typer.Exit(1)
207
+ if src == tgt:
208
+ output.print_err("src==tgt", "source and target must differ", json_out)
209
+ raise typer.Exit(1)
210
+ if not text.strip():
211
+ output.print_err("empty text", "provide TEXT", json_out)
212
+ raise typer.Exit(1)
213
+ _check_output(o, json_out)
214
+ with _api(cfg, json_out) as c:
215
+ r = c.post(
216
+ cfg["base_url"] + "/mt",
217
+ headers={**auth_headers(cfg["api_key"], idempotency_key), "Content-Type": "application/json"},
218
+ json={"text": text, "source_lang": src, "target_lang": tgt},
219
+ )
220
+ raise_for_api(r.status_code, r.text, "/mt")
221
+ output.present(
222
+ r.text,
223
+ {
224
+ "units": r.headers.get("x-units", ""),
225
+ "cache": r.headers.get("x-cache-status", ""),
226
+ "request_id": r.headers.get("x-request-id", ""),
227
+ },
228
+ json_out, o,
229
+ )
230
+
231
+ @app.command()
232
+ def diarize(
233
+ file: str = typer.Argument(..., help="Audio file (≤30MB)"),
234
+ o: str | None = typer.Option(None, "-o", "--output"),
235
+ fmt: str = typer.Option("json", "--format", help="json|txt|srt"),
236
+ idempotency_key: str | None = typer.Option(None, "--idempotency-key"),
237
+ json_out: bool = typer.Option(False, "--json", help="Machine-readable single-JSON output."),
238
+ ) -> None:
239
+ cfg = _cfg(None, None)
240
+ if fmt not in ("json", "txt", "srt"):
241
+ output.print_err(f"bad format {fmt}", "use --format json|txt|srt", json_out)
242
+ raise typer.Exit(1)
243
+ p = _audio_path(file, json_out)
244
+ _check_output(o, json_out)
245
+ with _api(cfg, json_out) as c, _open_audio(p, json_out) as fh:
246
+ r = c.post(
247
+ cfg["base_url"] + "/diarize",
248
+ headers=auth_headers(cfg["api_key"], idempotency_key),
249
+ files={"file": (p.name, fh)},
250
+ )
251
+ raise_for_api(r.status_code, r.text, "/diarize")
252
+ try:
253
+ data = r.json()
254
+ segs = data.get("segments", []) if isinstance(data, dict) else None
255
+ if not isinstance(segs, list) or not all(isinstance(s, dict) for s in segs):
256
+ raise ValueError("segments must be a list of objects")
257
+ rendered = _render_segments(segs, fmt)
258
+ except (TypeError, ValueError):
259
+ output.print_err("invalid diarize response", "try `duhub health --detailed`", json_out)
260
+ raise typer.Exit(1)
261
+ output.present(
262
+ rendered,
263
+ {"units": r.headers.get("x-units", ""), "cache": "", "request_id": r.headers.get("x-request-id", "")},
264
+ json_out, o,
265
+ )
266
+
267
+
268
+ if __name__ == "__main__":
269
+ app()
du_cli/output.py ADDED
@@ -0,0 +1,104 @@
1
+ """Human (Rich) vs machine (--json) output. No binary to stdout."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.markup import escape
11
+
12
+ _console = Console()
13
+ _console_err = Console(stderr=True)
14
+
15
+ def summary_line(units: str | None, cache: str | None, request_id: str | None) -> str:
16
+ parts = []
17
+ if units is not None:
18
+ parts.append(f"units={units}")
19
+ if cache is not None:
20
+ parts.append(f"cache={cache}")
21
+ if request_id is not None:
22
+ parts.append(f"request-id={request_id}")
23
+ return " ".join(parts) if parts else "ok"
24
+
25
+ def render_segments(segments: list[dict], fmt: str) -> str:
26
+ if fmt == "json":
27
+ return json.dumps(segments, ensure_ascii=False, indent=2)
28
+ if fmt == "txt":
29
+ return "\n".join(f"[Speaker {s.get('speaker')}] {s.get('text', '')}" for s in segments)
30
+ if fmt == "srt":
31
+ def ts(x: float) -> str:
32
+ total_ms = round(float(x) * 1000)
33
+ h, rem = divmod(total_ms, 3_600_000)
34
+ m, rem = divmod(rem, 60_000)
35
+ sec, ms = divmod(rem, 1000)
36
+ return f"{h:02d}:{m:02d}:{sec:02d},{ms:03d}"
37
+ lines = []
38
+ for i, s in enumerate(segments, 1):
39
+ lines.append(str(i))
40
+ lines.append(f"{ts(float(s.get('start', 0)))} --> {ts(float(s.get('end', 0)))}")
41
+ lines.append(f"[Speaker {s.get('speaker')}] {s.get('text', '')}")
42
+ lines.append("")
43
+ return "\n".join(lines)
44
+ raise ValueError(f"unknown format: {fmt}")
45
+
46
+ def print_ok(summary: dict[str, str], as_json: bool) -> None:
47
+ if as_json:
48
+ print(json.dumps(summary, ensure_ascii=False))
49
+ else:
50
+ _console.print(f"[green]ok[/green] {summary_line(summary.get('units'), summary.get('cache'), summary.get('request_id'))}")
51
+
52
+ def print_err(error: str, hint: str, as_json: bool) -> None:
53
+ if as_json:
54
+ print(json.dumps({"error": error, "hint": hint}, ensure_ascii=False), file=sys.stderr)
55
+ else:
56
+ _console_err.print(f"[red]error:[/red] {escape(error)}\nhint: {escape(hint)}")
57
+
58
+ def check_output_path(path: str, as_json: bool) -> None:
59
+ """Fail early (before a billed API call) when the output path is unusable."""
60
+ p = Path(path)
61
+ try:
62
+ if p.exists() and p.is_dir():
63
+ raise IsADirectoryError(f"is a directory: {path}")
64
+ parent = p.parent
65
+ if not parent.exists():
66
+ raise FileNotFoundError(f"parent directory does not exist: {parent}")
67
+ if not parent.is_dir():
68
+ raise NotADirectoryError(f"not a directory: {parent}")
69
+ except OSError as e:
70
+ print_err(f"cannot write {path}: {e}", "check the path and permissions", as_json)
71
+ raise typer.Exit(1)
72
+
73
+ def _write(path: str, data: str | bytes, as_json: bool) -> None:
74
+ check_output_path(path, as_json)
75
+ try:
76
+ p = Path(path)
77
+ if isinstance(data, bytes):
78
+ p.write_bytes(data)
79
+ else:
80
+ p.write_text(data, encoding="utf-8")
81
+ except OSError as e:
82
+ print_err(f"cannot write {path}: {e}", "check the path and permissions", as_json)
83
+ raise typer.Exit(1)
84
+
85
+ def save_bytes(path: str, data: bytes, as_json: bool) -> None:
86
+ """Write a binary payload (e.g. TTS wav) with a clean CLI error on failure."""
87
+ _write(path, data, as_json)
88
+
89
+ def present(text: str, summary: dict[str, str], as_json: bool, o: str | None) -> None:
90
+ """One payload outlet: -o writes the raw file; stdout is a single JSON
91
+ object under --json, else raw text plus the human ok-line."""
92
+ if o:
93
+ _write(o, text, as_json)
94
+ print_ok(summary, as_json)
95
+ return
96
+ if as_json:
97
+ try:
98
+ data = json.loads(text)
99
+ except ValueError:
100
+ data = text
101
+ print(json.dumps({"data": data, **summary}, ensure_ascii=False))
102
+ else:
103
+ print(text)
104
+ print_ok(summary, False)
@@ -0,0 +1,193 @@
1
+ Metadata-Version: 2.4
2
+ Name: duhub-cli
3
+ Version: 0.1.0
4
+ Summary: DUhub CLI — STT, TTS, MT and diarization over your API key.
5
+ Author: Digital Umuganda
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://beta.duhub.ai
8
+ Project-URL: Repository, https://github.com/Digital-Umuganda/duhub-cli
9
+ Project-URL: Issues, https://github.com/Digital-Umuganda/duhub-cli/issues
10
+ Keywords: kinyarwanda,stt,tts,translation,diarization,speech
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
19
+ Requires-Python: >=3.11
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: typer>=0.9
23
+ Requires-Dist: rich>=13
24
+ Requires-Dist: httpx>=0.27
25
+ Dynamic: license-file
26
+
27
+ # duhub-cli
28
+
29
+ Command-line client for [DUhub](https://beta.duhub.ai):
30
+ transcribe audio, synthesize speech, translate text, and diarize meetings —
31
+ all billed to your API key.
32
+
33
+ ```bash
34
+ duhub stt rec.wav -o transcript.json
35
+ duhub tts "Muraho neza nshuti yanjye." -o out.wav
36
+ duhub mt "Muraho" --src rw --tgt en
37
+ duhub diarize meeting.wav --format srt -o speakers.srt
38
+ ```
39
+
40
+ ## Installation
41
+
42
+ Requires Python 3.11+.
43
+
44
+ ```bash
45
+ pipx install duhub-cli
46
+ duhub --version
47
+ ```
48
+
49
+ Alternatives:
50
+
51
+ ```bash
52
+ pipx install git+https://github.com/Digital-Umuganda/duhub-cli.git
53
+ pip install git+https://github.com/Digital-Umuganda/duhub-cli.git # plain pip
54
+ python -m du_cli.main --help # without installing
55
+ ```
56
+
57
+ > The installed command is `duhub` only, so it does not shadow the Unix
58
+ > `du` (disk usage). You can also invoke `python -m du_cli.main` without
59
+ > installing.
60
+
61
+ ## Quickstart
62
+
63
+ 1. Create an account on the
64
+ [Dashboard](https://beta.duhub.ai) and copy an
65
+ API key from **API Keys** (shown once — treat it like a password).
66
+ 2. Save it:
67
+ ```bash
68
+ duhub configure --api-key du_live_xxxxxxxxxxxxxxxx
69
+ ```
70
+ 3. Check connectivity:
71
+ ```bash
72
+ duhub health
73
+ duhub health --detailed
74
+ ```
75
+ 4. Transcribe something:
76
+ ```bash
77
+ duhub stt rec.wav
78
+ ```
79
+
80
+ ## Configuration
81
+
82
+ `duhub configure` stores settings in `~/.config/du/config.toml`:
83
+
84
+ | Source | Priority | Example |
85
+ |--------|----------|---------|
86
+ | Environment | Highest (always wins) | `DU_API_KEY`, `DU_BASE_URL` |
87
+ | Config file | Middle | `duhub configure --api-key … --base-url …` |
88
+ | Built-in default | Lowest | base `https://du-api.dev.umuganda.digital` |
89
+
90
+ ```bash
91
+ duhub configure # show current state
92
+ duhub configure --api-key du_live_xxx # set key
93
+ duhub configure --base-url http://localhost:8080 # point at local gateway
94
+ duhub configure --reset # delete the file
95
+ export DU_API_KEY=du_live_xxx DU_BASE_URL=https://du-api.dev.umuganda.digital
96
+ ```
97
+
98
+ > Prefer `DU_API_KEY` env var over `--api-key` — flags land in shell history.
99
+
100
+ ## Commands
101
+
102
+ Every command exits `0` on success, `1` on failure (`2` on CLI usage
103
+ errors) and prints a summary line (`units`, `cache`, `request-id`). The
104
+ API commands (`health`, `stt`, `tts`, `mt`, `diarize`) accept `--json`
105
+ for a single machine-readable object on stdout
106
+ (`{"data", "units", "cache", "request_id"}`). `-o` writes the raw
107
+ payload to a file instead of stdout.
108
+
109
+ Every billed command (`stt`, `tts`, `mt`, `diarize`) accepts
110
+ `--idempotency-key`, so a retry after a timeout or connection drop is
111
+ deduplicated by the gateway instead of being billed twice.
112
+
113
+ ### `duhub health`
114
+
115
+ Liveness (`/health`) or full readiness (`--detailed`, checks STT/TTS/MT
116
+ backends — the one to try first when calls fail).
117
+
118
+ ### `duhub stt` — speech to text
119
+
120
+ Upload audio (WAV/FLAC/OGG, ≤ 30 MB), get `{"text": "…"}`.
121
+ **Billed per audio second.**
122
+
123
+ ```bash
124
+ duhub stt rec.wav -o transcript.json
125
+ duhub stt rec.wav --idempotency-key run-42 -o transcript.json
126
+ ```
127
+
128
+ ### `duhub tts` — text to speech
129
+
130
+ Synthesize Kinyarwanda speech to a 24 kHz mono WAV (`-o` is required —
131
+ audio never goes to stdout). **Billed per word.** `--text-file`
132
+ overrides `TEXT` when both are given.
133
+
134
+ ```bash
135
+ duhub tts "Muraho neza nshuti yanjye." -o out.wav
136
+ duhub tts "Muraho neza nshuti yanjye." -o out.wav --idempotency-key run-42
137
+ ```
138
+
139
+ ### `duhub mt` — translation
140
+
141
+ Translate between Kinyarwanda (`rw`) and English (`en`).
142
+ **Billed per input word.**
143
+
144
+ ```bash
145
+ duhub mt "Muraho" --src rw --tgt en
146
+ ```
147
+
148
+ ### `duhub diarize` — who spoke when
149
+
150
+ Upload audio, get speaker-labelled segments. `--format` renders the same
151
+ result as `json` (default), `txt`, or `srt`. **Billed per audio second.**
152
+
153
+ ```bash
154
+ duhub diarize meeting.wav --format srt -o speakers.srt
155
+ ```
156
+
157
+ > A `404` here usually means the configured gateway predates the
158
+ > `/diarize` route. Check `duhub health --detailed` and your `--base-url`.
159
+
160
+ ## Scripting
161
+
162
+ `--json` prints exactly one JSON object — pipe-safe:
163
+
164
+ ```bash
165
+ TEXT=$(duhub stt rec.wav --json | jq -r .data.text)
166
+
167
+ for f in clips/*.wav; do
168
+ duhub stt "$f" -o "${f%.wav}.json" || echo "FAILED: $f" >&2
169
+ done
170
+ ```
171
+
172
+ ## Errors
173
+
174
+ API, input-file, and output-file failures are reported as `error` +
175
+ `hint` — no tracebacks:
176
+
177
+ | You see | Cause | Fix |
178
+ |---------|-------|-----|
179
+ | `invalid key` (401) | Missing/unknown/revoked key | Copy a fresh key from dashboard API Keys |
180
+ | `insufficient balance` (402) | Wallet empty | Top-up via dashboard Wallet |
181
+ | `organization pending review` (403) | Org not approved yet | Contact your org admin |
182
+ | `file exceeds 30 MB` | Upload guard | Compress or split the audio |
183
+ | `server busy (saturated)` (503) | GPU at concurrency cap | Wait `Retry-After` seconds, retry |
184
+ | `backend unreachable` (502) | Model container down | `duhub health --detailed` to see which |
185
+ | `cannot reach server` | Wrong `--base-url`, offline, timeout | Check URL and connectivity |
186
+
187
+ ## Development
188
+
189
+ ```bash
190
+ uv sync # or: python -m venv .venv && .venv/bin/pip install -e .
191
+ uv run pytest
192
+ uv run duhub --help
193
+ ```
@@ -0,0 +1,11 @@
1
+ du_cli/__init__.py,sha256=ZdroH7KM49mKbJMV4Dnl_FVAlDjRHaRWgn3sjWnkE2s,57
2
+ du_cli/client.py,sha256=JvPZfXmNKg5KA4U-vJQEahNpZCErWlHWwJE2KyblEhY,2308
3
+ du_cli/config.py,sha256=yF29tGA-1uy_kk9xO1PfL75f8CDwVTGZQQYThdlmzuw,2423
4
+ du_cli/main.py,sha256=zwA_aalTJtynMepxpe7xH5IBFzuYdXNr8Bpary_bR_0,10227
5
+ du_cli/output.py,sha256=H5luZKqtmQ4OC6xoBDj-dsC0zKFYFiBq6JLZlLyujRQ,3933
6
+ duhub_cli-0.1.0.dist-info/licenses/LICENSE,sha256=wxI7NZ1OvtXusPXaEmhy8efO9XmMa2kKaP93sNm4ZZ8,1073
7
+ duhub_cli-0.1.0.dist-info/METADATA,sha256=_MSkIXA-WzUcPxVGvnv1VPC8yMIgwbfEJzezdTosAp8,6081
8
+ duhub_cli-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
9
+ duhub_cli-0.1.0.dist-info/entry_points.txt,sha256=p4hxWWISD3zKwahc6VfelZZLtmoIctfb1fkDko1xzXQ,42
10
+ duhub_cli-0.1.0.dist-info/top_level.txt,sha256=RLpZ8i7eI0S9TUmL0jPcQLB9lG3er8Lr-IcNYm8Zlxc,7
11
+ duhub_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ duhub = du_cli.main:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Digital Umuganda
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ du_cli