codeberg-cli 0.1.0__py3-none-any.whl → 0.2.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.
Files changed (43) hide show
  1. codeberg_cli/__main__.py +1 -1
  2. codeberg_cli/client.py +20 -5
  3. codeberg_cli/git.py +18 -7
  4. codeberg_cli/helpers.py +30 -5
  5. codeberg_cli/routes/__init__.py +6 -3
  6. codeberg_cli/routes/auth/__init__.py +6 -1
  7. codeberg_cli/routes/auth/login.py +0 -2
  8. codeberg_cli/routes/auth/status.py +0 -2
  9. codeberg_cli/routes/auth/whoami.py +0 -2
  10. codeberg_cli/routes/issue/__init__.py +6 -1
  11. codeberg_cli/routes/issue/comment.py +41 -0
  12. codeberg_cli/routes/issue/create.py +2 -2
  13. codeberg_cli/routes/issue/edit.py +38 -0
  14. codeberg_cli/routes/issue/view.py +2 -2
  15. codeberg_cli/routes/label/__init__.py +6 -1
  16. codeberg_cli/routes/milestone/__init__.py +6 -1
  17. codeberg_cli/routes/notification/__init__.py +6 -0
  18. codeberg_cli/routes/notification/list.py +38 -0
  19. codeberg_cli/routes/pr/__init__.py +6 -1
  20. codeberg_cli/routes/pr/comment.py +41 -0
  21. codeberg_cli/routes/pr/create.py +2 -2
  22. codeberg_cli/routes/pr/edit.py +38 -0
  23. codeberg_cli/routes/pr/view.py +2 -2
  24. codeberg_cli/routes/release/__init__.py +6 -1
  25. codeberg_cli/routes/release/create.py +2 -2
  26. codeberg_cli/routes/release/upload.py +1 -1
  27. codeberg_cli/routes/release/view.py +2 -2
  28. codeberg_cli/routes/repo/__init__.py +6 -1
  29. codeberg_cli/routes/repo/clone.py +3 -2
  30. codeberg_cli/routes/repo/create.py +5 -4
  31. codeberg_cli/routes/repo/fork.py +1 -1
  32. codeberg_cli/routes/repo/star.py +25 -0
  33. codeberg_cli/routes/repo/unstar.py +25 -0
  34. codeberg_cli/routes/repo/view.py +4 -3
  35. codeberg_cli/routes/user/__init__.py +6 -0
  36. codeberg_cli/routes/user/view.py +26 -0
  37. codeberg_cli-0.2.0.dist-info/METADATA +118 -0
  38. codeberg_cli-0.2.0.dist-info/RECORD +59 -0
  39. codeberg_cli-0.1.0.dist-info/METADATA +0 -182
  40. codeberg_cli-0.1.0.dist-info/RECORD +0 -49
  41. {codeberg_cli-0.1.0.dist-info → codeberg_cli-0.2.0.dist-info}/WHEEL +0 -0
  42. {codeberg_cli-0.1.0.dist-info → codeberg_cli-0.2.0.dist-info}/entry_points.txt +0 -0
  43. {codeberg_cli-0.1.0.dist-info → codeberg_cli-0.2.0.dist-info}/licenses/LICENSE +0 -0
codeberg_cli/__main__.py CHANGED
@@ -2,7 +2,7 @@ from xclif import Cli
2
2
 
3
3
  from . import routes
4
4
 
5
- cli = Cli.from_routes(routes)
5
+ cli = Cli.from_routes(routes, config_name="codeberg-cli")
6
6
 
7
7
  if __name__ == "__main__":
8
8
  cli()
codeberg_cli/client.py CHANGED
@@ -2,7 +2,7 @@ import httpx
2
2
  from rich.status import Status
3
3
  from xclif.context import get_context
4
4
 
5
- BASE_URL = "https://codeberg.org/api/v1"
5
+ DEFAULT_BASE_URL = "https://codeberg.org"
6
6
 
7
7
  _VERBS = {
8
8
  "GET": "Fetching",
@@ -19,7 +19,7 @@ class ClientError(RuntimeError):
19
19
  class Client:
20
20
  """HTTP client for the Codeberg API."""
21
21
 
22
- def __init__(self, token: str | None = None) -> None:
22
+ def __init__(self, token: str | None = None, base_url: str | None = None) -> None:
23
23
  headers = {
24
24
  "Accept": "application/json",
25
25
  "User-Agent": "codeberg-cli/0.1.0",
@@ -27,7 +27,18 @@ class Client:
27
27
  }
28
28
  if token:
29
29
  headers["Authorization"] = f"Bearer {token}"
30
- self._client = httpx.Client(base_url=BASE_URL, headers=headers, timeout=30.0)
30
+ raw = base_url or DEFAULT_BASE_URL
31
+ if "://" not in raw:
32
+ raw = "https://" + raw
33
+ raw = raw.rstrip("/")
34
+ if not raw.endswith("/api/v1"):
35
+ raw += "/api/v1"
36
+ self._client = httpx.Client(base_url=raw, headers=headers, timeout=30.0)
37
+
38
+ @property
39
+ def base_url(self) -> str:
40
+ """The API base URL used by this client."""
41
+ return str(self._client.base_url)
31
42
 
32
43
  def get(
33
44
  self, path: str, params: dict | None = None, action: str | None = None
@@ -50,10 +61,14 @@ class Client:
50
61
  def _request(
51
62
  self, method: str, path: str, action: str | None = None, **kwargs
52
63
  ) -> dict | list | None:
53
- ctx = get_context()
64
+ try:
65
+ ctx = get_context()
66
+ verbosity = ctx.verbosity
67
+ except RuntimeError:
68
+ verbosity = 0
54
69
  label = action or (
55
70
  f"{method} {path}"
56
- if ctx.verbosity >= 2
71
+ if verbosity >= 2
57
72
  else f"{_VERBS.get(method, method)} {path.split('/')[-1]}"
58
73
  )
59
74
  with Status(f"{label}..."):
codeberg_cli/git.py CHANGED
@@ -1,16 +1,27 @@
1
- import re
2
1
  import subprocess
3
2
  from pathlib import Path
3
+ from urllib.parse import urlparse
4
4
 
5
5
 
6
6
  def parse_repo_from_remote(remote_url: str) -> str:
7
7
  """Extract owner/repo from a git remote URL."""
8
- # HTTPS: https://codeberg.org/owner/repo.git
9
- # SSH: git@codeberg.org:owner/repo.git
10
- m = re.search(r"codeberg\.org[:/](.+?)(?:\.git)?/?$", remote_url)
11
- if m:
12
- return m.group(1)
13
- raise ValueError(f"Could not parse repo from remote: {remote_url}")
8
+ # HTTPS: https://hostname/owner/repo.git
9
+ parsed = urlparse(remote_url)
10
+ if parsed.scheme in ("http", "https"):
11
+ path = parsed.path
12
+ elif "@" in remote_url:
13
+ # SSH: git@hostname:owner/repo.git
14
+ path = remote_url.split(":", 1)[1]
15
+ else:
16
+ raise ValueError(f"Could not parse repo from remote: {remote_url}")
17
+
18
+ path = path.rstrip("/")
19
+ if path.endswith(".git"):
20
+ path = path[:-4]
21
+ owner_repo = path.removeprefix("/")
22
+ if "/" not in owner_repo:
23
+ raise ValueError(f"Could not parse repo from remote: {remote_url}")
24
+ return owner_repo
14
25
 
15
26
 
16
27
  def get_default_branch(cwd: Path) -> str:
codeberg_cli/helpers.py CHANGED
@@ -1,20 +1,45 @@
1
- from codeberg_cli.client import Client, ClientError
1
+ from codeberg_cli.client import Client, ClientError, DEFAULT_BASE_URL
2
2
  from codeberg_cli.config import load_config
3
3
 
4
4
 
5
- def get_authenticated_client() -> Client | None:
5
+ def get_base_url() -> str:
6
+ """Get the web base URL from cascading CLI context or default."""
7
+ from xclif.context import get_context
8
+
9
+ try:
10
+ ctx = get_context()
11
+ raw = ctx.get("base_url", DEFAULT_BASE_URL)
12
+ except RuntimeError:
13
+ raw = DEFAULT_BASE_URL
14
+
15
+ # Normalize: ensure scheme, strip API path suffix for backward compat
16
+ if "://" not in raw:
17
+ raw = "https://" + raw
18
+ raw = raw.rstrip("/")
19
+ if raw.endswith("/api/v1"):
20
+ raw = raw[:-7]
21
+ return raw
22
+
23
+
24
+ def get_web_base_url() -> str:
25
+ """Derive the web UI base URL from the API base URL."""
26
+ return get_base_url()
27
+
28
+
29
+ def get_authenticated_client(base_url: str | None = None) -> Client | None:
6
30
  """Return a Client if token is stored, else None."""
7
31
  config = load_config()
8
32
  token = config.get("token")
9
33
  if not token:
10
34
  return None
11
- return Client(token=token)
35
+ return Client(token=token, base_url=base_url or get_base_url())
12
36
 
13
37
 
14
- def require_client() -> Client:
38
+ def require_client(base_url: str | None = None) -> Client:
15
39
  """Return an authenticated Client or print error and exit."""
16
- client = get_authenticated_client()
40
+ client = get_authenticated_client(base_url=base_url)
17
41
  if client is None:
18
42
  from xclif.errors import UsageError
43
+
19
44
  raise UsageError("Not logged in. Run 'cb auth login' first.")
20
45
  return client
@@ -1,6 +1,9 @@
1
- from xclif import command
1
+ from codeberg_cli.client import DEFAULT_BASE_URL
2
+ from xclif import Cascade, WithConfig, command
2
3
 
3
4
 
4
5
  @command("cb")
5
- def _() -> None:
6
- """A Codeberg CLI."""
6
+ def _(
7
+ base_url: Cascade[WithConfig[str]] = DEFAULT_BASE_URL,
8
+ ) -> None:
9
+ """Interact with Codeberg or any Forgejo instance — manage repos, issues, PRs, releases, and more."""
@@ -1 +1,6 @@
1
- # Namespace package for auth subcommands
1
+ from xclif import command
2
+
3
+
4
+ @command("auth")
5
+ def _() -> None:
6
+ """Manage authentication — login, logout, check status, and whoami."""
@@ -19,5 +19,3 @@ def _(
19
19
  user = client.get("/user", action="Verifying token")
20
20
  save_config({"token": token})
21
21
  rich.print(f"[bold green]Logged in as[/bold green] [bold]{user['login']}[/bold]")
22
-
23
-
@@ -17,5 +17,3 @@ def _() -> None:
17
17
  rich.print(f"Logged in to [cyan]Codeberg[/cyan] as [bold]{user['login']}[/bold]")
18
18
  rich.print(f" User ID: {user['id']}")
19
19
  rich.print(f" Full Name: {user.get('full_name', '(not set)')}")
20
-
21
-
@@ -10,5 +10,3 @@ def _() -> None:
10
10
  client = require_client()
11
11
  user = client.get("/user")
12
12
  print(user["login"])
13
-
14
-
@@ -1 +1,6 @@
1
- # Namespace package for issue subcommands
1
+ from xclif import command
2
+
3
+
4
+ @command("issue")
5
+ def _() -> None:
6
+ """Manage issues — create, view, close, and more."""
@@ -0,0 +1,41 @@
1
+ from typing import Annotated
2
+
3
+ import rich
4
+
5
+ from codeberg_cli.git import infer_repo
6
+ from codeberg_cli.helpers import require_client
7
+ from xclif import Arg, Option, command
8
+
9
+
10
+ @command("comment")
11
+ def _(
12
+ id: Annotated[int, Arg(description="Issue number")],
13
+ message: Annotated[str, Option(description="Comment text", name="message")] = "",
14
+ repo: Annotated[str, Option(description="Repository (owner/repo)", name="repo")] = "",
15
+ ) -> None:
16
+ """Comment on an issue."""
17
+ client = require_client()
18
+
19
+ if not repo:
20
+ inferred = infer_repo()
21
+ if not inferred:
22
+ rich.print("[bold red]Error:[/bold red] No repo specified and not in a git directory")
23
+ return 1
24
+ repo = inferred
25
+
26
+ if not message:
27
+ rich.print("[dim]Enter comment (Ctrl+D to finish):[/dim]")
28
+ try:
29
+ lines = []
30
+ while True:
31
+ line = input()
32
+ lines.append(line)
33
+ except (EOFError, KeyboardInterrupt):
34
+ pass
35
+ message = "\n".join(lines).strip()
36
+ if not message:
37
+ rich.print("[bold red]Error:[/bold red] Comment cannot be empty")
38
+ return 1
39
+
40
+ client.post(f"/repos/{repo}/issues/{id}/comments", data={"body": message})
41
+ rich.print(f"[bold green]Commented[/bold green] on issue #{id} in {repo}")
@@ -3,7 +3,7 @@ from typing import Annotated
3
3
  import rich
4
4
 
5
5
  from codeberg_cli.git import infer_repo
6
- from codeberg_cli.helpers import require_client
6
+ from codeberg_cli.helpers import get_web_base_url, require_client
7
7
  from xclif import Option, command
8
8
 
9
9
 
@@ -50,6 +50,6 @@ def _(
50
50
  result = client.post(f"/repos/{repo}/issues", data=data)
51
51
 
52
52
  rich.print(f"[bold green]Created issue[/bold green] #{result['number']}: {result['title']}")
53
- rich.print(f"[dim]https://codeberg.org/{repo}/issues/{result['number']}[/dim]")
53
+ rich.print(f"[dim]{get_web_base_url()}/{repo}/issues/{result['number']}[/dim]")
54
54
 
55
55
 
@@ -0,0 +1,38 @@
1
+ from typing import Annotated
2
+
3
+ import rich
4
+
5
+ from codeberg_cli.git import infer_repo
6
+ from codeberg_cli.helpers import require_client
7
+ from xclif import Arg, Option, command
8
+
9
+
10
+ @command("edit")
11
+ def _(
12
+ id: Annotated[int, Arg(description="Issue number")],
13
+ title: Annotated[str, Option(description="New title", name="title")] = "",
14
+ body: Annotated[str, Option(description="New body", name="body")] = "",
15
+ repo: Annotated[str, Option(description="Repository (owner/repo)", name="repo")] = "",
16
+ ) -> None:
17
+ """Edit an issue's title or body."""
18
+ client = require_client()
19
+
20
+ if not repo:
21
+ inferred = infer_repo()
22
+ if not inferred:
23
+ rich.print("[bold red]Error:[/bold red] No repo specified and not in a git directory")
24
+ return 1
25
+ repo = inferred
26
+
27
+ data: dict = {}
28
+ if title:
29
+ data["title"] = title
30
+ if body:
31
+ data["body"] = body
32
+
33
+ if not data:
34
+ rich.print("[bold yellow]Nothing to update.[/bold yellow] Pass --title and/or --body.")
35
+ return 1
36
+
37
+ client.patch(f"/repos/{repo}/issues/{id}", data=data)
38
+ rich.print(f"[bold green]Updated[/bold green] issue #{id} in {repo}")
@@ -4,7 +4,7 @@ from typing import Annotated
4
4
  import rich
5
5
 
6
6
  from codeberg_cli.git import infer_repo
7
- from codeberg_cli.helpers import require_client
7
+ from codeberg_cli.helpers import get_web_base_url, require_client
8
8
  from xclif import Arg, Option, command
9
9
 
10
10
 
@@ -25,7 +25,7 @@ def _(
25
25
  repo = inferred
26
26
 
27
27
  if web:
28
- webbrowser.open(f"https://codeberg.org/{repo}/issues/{id}")
28
+ webbrowser.open(f"{get_web_base_url()}/{repo}/issues/{id}")
29
29
  return
30
30
 
31
31
  issue = client.get(f"/repos/{repo}/issues/{id}")
@@ -1 +1,6 @@
1
- # Namespace package for label subcommands
1
+ from xclif import command
2
+
3
+
4
+ @command("label")
5
+ def _() -> None:
6
+ """Manage repository labels — create, list, and delete."""
@@ -1 +1,6 @@
1
- # Namespace package for milestone subcommands
1
+ from xclif import command
2
+
3
+
4
+ @command("milestone")
5
+ def _() -> None:
6
+ """Manage milestones — create and list."""
@@ -0,0 +1,6 @@
1
+ from xclif import command
2
+
3
+
4
+ @command("notification")
5
+ def _() -> None:
6
+ """View notifications from your repositories."""
@@ -0,0 +1,38 @@
1
+ from typing import Annotated
2
+
3
+ import rich
4
+ from rich.table import Table
5
+
6
+ from codeberg_cli.helpers import require_client
7
+ from xclif import Option, command
8
+
9
+
10
+ @command("list", "ls")
11
+ def _(
12
+ limit: Annotated[int, Option(description="Maximum notifications to show", name="limit")] = 30,
13
+ all: Annotated[bool, Option(description="Show all notifications, not just unread", name="all")] = False,
14
+ ) -> None:
15
+ """List notifications."""
16
+ client = require_client()
17
+
18
+ params: dict = {"limit": limit, "page": 1}
19
+ if not all:
20
+ params["status-types"] = "unread"
21
+
22
+ notifications = client.get("/notifications", params=params)
23
+
24
+ if not notifications:
25
+ rich.print("[dim]No notifications.[/dim]")
26
+ return
27
+
28
+ table = Table("ID", "Type", "Subject", "Repo")
29
+ for n in notifications:
30
+ subject = n.get("subject", {})
31
+ repo = n.get("repository", {})
32
+ table.add_row(
33
+ str(n["id"]),
34
+ subject.get("type", ""),
35
+ subject.get("title", ""),
36
+ repo.get("full_name", ""),
37
+ )
38
+ rich.print(table)
@@ -1 +1,6 @@
1
- # Namespace package for pr subcommands
1
+ from xclif import command
2
+
3
+
4
+ @command("pr")
5
+ def _() -> None:
6
+ """Manage pull requests — create, list, comment, and more."""
@@ -0,0 +1,41 @@
1
+ from typing import Annotated
2
+
3
+ import rich
4
+
5
+ from codeberg_cli.git import infer_repo
6
+ from codeberg_cli.helpers import require_client
7
+ from xclif import Arg, Option, command
8
+
9
+
10
+ @command("comment")
11
+ def _(
12
+ id: Annotated[int, Arg(description="PR number")],
13
+ message: Annotated[str, Option(description="Comment text", name="message")] = "",
14
+ repo: Annotated[str, Option(description="Repository (owner/repo)", name="repo")] = "",
15
+ ) -> None:
16
+ """Comment on a pull request."""
17
+ client = require_client()
18
+
19
+ if not repo:
20
+ inferred = infer_repo()
21
+ if not inferred:
22
+ rich.print("[bold red]Error:[/bold red] No repo specified and not in a git directory")
23
+ return 1
24
+ repo = inferred
25
+
26
+ if not message:
27
+ rich.print("[dim]Enter comment (Ctrl+D to finish):[/dim]")
28
+ try:
29
+ lines = []
30
+ while True:
31
+ line = input()
32
+ lines.append(line)
33
+ except (EOFError, KeyboardInterrupt):
34
+ pass
35
+ message = "\n".join(lines).strip()
36
+ if not message:
37
+ rich.print("[bold red]Error:[/bold red] Comment cannot be empty")
38
+ return 1
39
+
40
+ client.post(f"/repos/{repo}/issues/{id}/comments", data={"body": message})
41
+ rich.print(f"[bold green]Commented[/bold green] on PR #{id} in {repo}")
@@ -5,7 +5,7 @@ from typing import Annotated
5
5
  import rich
6
6
 
7
7
  from codeberg_cli.git import get_default_branch, infer_repo
8
- from codeberg_cli.helpers import require_client
8
+ from codeberg_cli.helpers import get_web_base_url, require_client
9
9
  from xclif import Option, command
10
10
 
11
11
 
@@ -63,6 +63,6 @@ def _(
63
63
 
64
64
  result = client.post(f"/repos/{repo}/pulls", data=data)
65
65
  rich.print(f"[bold green]Created PR[/bold green] #{result['number']}: {result['title']}")
66
- rich.print(f"[dim]https://codeberg.org/{repo}/pulls/{result['number']}[/dim]")
66
+ rich.print(f"[dim]{get_web_base_url()}/{repo}/pulls/{result['number']}[/dim]")
67
67
 
68
68
 
@@ -0,0 +1,38 @@
1
+ from typing import Annotated
2
+
3
+ import rich
4
+
5
+ from codeberg_cli.git import infer_repo
6
+ from codeberg_cli.helpers import require_client
7
+ from xclif import Arg, Option, command
8
+
9
+
10
+ @command("edit")
11
+ def _(
12
+ id: Annotated[int, Arg(description="PR number")],
13
+ title: Annotated[str, Option(description="New title", name="title")] = "",
14
+ body: Annotated[str, Option(description="New body", name="body")] = "",
15
+ repo: Annotated[str, Option(description="Repository (owner/repo)", name="repo")] = "",
16
+ ) -> None:
17
+ """Edit a pull request's title or body."""
18
+ client = require_client()
19
+
20
+ if not repo:
21
+ inferred = infer_repo()
22
+ if not inferred:
23
+ rich.print("[bold red]Error:[/bold red] No repo specified and not in a git directory")
24
+ return 1
25
+ repo = inferred
26
+
27
+ data: dict = {}
28
+ if title:
29
+ data["title"] = title
30
+ if body:
31
+ data["body"] = body
32
+
33
+ if not data:
34
+ rich.print("[bold yellow]Nothing to update.[/bold yellow] Pass --title and/or --body.")
35
+ return 1
36
+
37
+ client.patch(f"/repos/{repo}/pulls/{id}", data=data)
38
+ rich.print(f"[bold green]Updated[/bold green] PR #{id} in {repo}")
@@ -4,7 +4,7 @@ from typing import Annotated
4
4
  import rich
5
5
 
6
6
  from codeberg_cli.git import infer_repo
7
- from codeberg_cli.helpers import require_client
7
+ from codeberg_cli.helpers import get_web_base_url, require_client
8
8
  from xclif import Arg, Option, command
9
9
 
10
10
 
@@ -25,7 +25,7 @@ def _(
25
25
  repo = inferred
26
26
 
27
27
  if web:
28
- webbrowser.open(f"https://codeberg.org/{repo}/pulls/{id}")
28
+ webbrowser.open(f"{get_web_base_url()}/{repo}/pulls/{id}")
29
29
  return
30
30
 
31
31
  pr = client.get(f"/repos/{repo}/pulls/{id}")
@@ -1 +1,6 @@
1
- # Namespace package for release subcommands
1
+ from xclif import command
2
+
3
+
4
+ @command("release")
5
+ def _() -> None:
6
+ """Manage releases — create, list, view, and upload assets."""
@@ -3,7 +3,7 @@ from typing import Annotated
3
3
  import rich
4
4
 
5
5
  from codeberg_cli.git import infer_repo
6
- from codeberg_cli.helpers import require_client
6
+ from codeberg_cli.helpers import get_web_base_url, require_client
7
7
  from xclif import Arg, Option, command
8
8
 
9
9
 
@@ -36,5 +36,5 @@ def _(
36
36
 
37
37
  result = client.post(f"/repos/{repo}/releases", data=data)
38
38
  rich.print(f"[bold green]Created release[/bold green] {result.get('name', tag)}")
39
- rich.print(f"[dim]https://codeberg.org/{repo}/releases/tag/{tag}[/dim]")
39
+ rich.print(f"[dim]{get_web_base_url()}/{repo}/releases/tag/{tag}[/dim]")
40
40
 
@@ -38,7 +38,7 @@ def _(
38
38
  headers = {"Authorization": f"Bearer {token}"}
39
39
 
40
40
  response = httpx.post(
41
- f"https://codeberg.org/api/v1/repos/{repo}/releases/{id}/assets",
41
+ f"{client.base_url}/repos/{repo}/releases/{id}/assets",
42
42
  headers=headers,
43
43
  files={"attachment": (file_path.name, content)},
44
44
  )
@@ -4,7 +4,7 @@ from typing import Annotated
4
4
  import rich
5
5
 
6
6
  from codeberg_cli.git import infer_repo
7
- from codeberg_cli.helpers import require_client
7
+ from codeberg_cli.helpers import get_web_base_url, require_client
8
8
  from xclif import Arg, Option, command
9
9
 
10
10
 
@@ -27,7 +27,7 @@ def _(
27
27
  release = client.get(f"/repos/{repo}/releases/{id}")
28
28
 
29
29
  if web:
30
- webbrowser.open(release.get("html_url", f"https://codeberg.org/{repo}/releases/tag/{release['tag_name']}"))
30
+ webbrowser.open(release.get("html_url", f"{get_web_base_url()}/{repo}/releases/tag/{release['tag_name']}"))
31
31
  return
32
32
 
33
33
  rich.print(f"[bold]{release.get('name', release['tag_name'])}[/bold]")
@@ -1 +1,6 @@
1
- # Namespace package for repo subcommands
1
+ from xclif import command
2
+
3
+
4
+ @command("repo")
5
+ def _() -> None:
6
+ """Manage repositories — create, list, star, and more."""
@@ -3,6 +3,7 @@ from typing import Annotated
3
3
 
4
4
  import rich
5
5
 
6
+ from codeberg_cli.helpers import get_web_base_url
6
7
  from xclif import Arg, command
7
8
 
8
9
 
@@ -10,8 +11,8 @@ from xclif import Arg, command
10
11
  def _(
11
12
  repo: Annotated[str, Arg(description="Repository in owner/repo format")],
12
13
  ) -> None:
13
- """Clone a Codeberg repository."""
14
- url = f"https://codeberg.org/{repo}.git"
14
+ """Clone a repository."""
15
+ url = f"{get_web_base_url()}/{repo}.git"
15
16
  rich.print(f"Cloning [cyan]{repo}[/cyan]...")
16
17
  result = subprocess.run(["git", "clone", url])
17
18
  if result.returncode != 0:
@@ -5,7 +5,7 @@ import rich
5
5
  from rich.prompt import Confirm, Prompt
6
6
 
7
7
  from codeberg_cli.git import get_repo_remote
8
- from codeberg_cli.helpers import require_client
8
+ from codeberg_cli.helpers import get_web_base_url, require_client
9
9
  from xclif import Option, command
10
10
 
11
11
 
@@ -36,14 +36,15 @@ def _(
36
36
  "auto_init": True,
37
37
  }
38
38
 
39
+ web = get_web_base_url()
39
40
  if org:
40
41
  result = client.post(f"/orgs/{org}/repos", data=data, action=f"Creating repository in {org}")
41
42
  owner = org
42
- repo_url = f"https://codeberg.org/{org}/{name}"
43
+ repo_url = f"{web}/{org}/{name}"
43
44
  else:
44
45
  result = client.post("/user/repos", data=data, action="Creating repository")
45
46
  owner = result["owner"]["login"]
46
- repo_url = f"https://codeberg.org/{owner}/{name}"
47
+ repo_url = f"{web}/{owner}/{name}"
47
48
 
48
49
  rich.print(f"[bold green]Created repository[/bold green] {repo_url}")
49
50
 
@@ -62,7 +63,7 @@ def _(
62
63
 
63
64
  try:
64
65
  subprocess.run(
65
- ["git", "remote", "add", "origin", f"https://codeberg.org/{owner}/{name}.git"],
66
+ ["git", "remote", "add", "origin", f"{web}/{owner}/{name}.git"],
66
67
  capture_output=True, text=True, check=True, cwd=cwd,
67
68
  )
68
69
  rich.print("[dim]Added remote 'origin'[/dim]")
@@ -4,7 +4,7 @@ import rich
4
4
 
5
5
  from codeberg_cli.git import infer_repo
6
6
  from codeberg_cli.helpers import require_client
7
- from xclif import Option, Arg, command
7
+ from xclif import Arg, Option, command
8
8
 
9
9
 
10
10
  @command("fork")
@@ -0,0 +1,25 @@
1
+ from typing import Annotated
2
+
3
+ import rich
4
+
5
+ from codeberg_cli.git import infer_repo
6
+ from codeberg_cli.helpers import require_client
7
+ from xclif import Option, command
8
+
9
+
10
+ @command("star")
11
+ def _(
12
+ repo: Annotated[str, Option(description="Repository to star")] = "",
13
+ ) -> None:
14
+ """Star a repository."""
15
+ client = require_client()
16
+
17
+ if not repo:
18
+ inferred = infer_repo()
19
+ if not inferred:
20
+ rich.print("[bold red]Error:[/bold red] No repo specified and not in a git directory")
21
+ return 1
22
+ repo = inferred
23
+
24
+ client.put(f"/user/starred/{repo}", action=f"Starring {repo}")
25
+ rich.print(f"[bold green]Starred[/bold green] {repo}")
@@ -0,0 +1,25 @@
1
+ from typing import Annotated
2
+
3
+ import rich
4
+
5
+ from codeberg_cli.git import infer_repo
6
+ from codeberg_cli.helpers import require_client
7
+ from xclif import Option, command
8
+
9
+
10
+ @command("unstar")
11
+ def _(
12
+ repo: Annotated[str, Option(description="Repository to unstar")] = "",
13
+ ) -> None:
14
+ """Unstar a repository."""
15
+ client = require_client()
16
+
17
+ if not repo:
18
+ inferred = infer_repo()
19
+ if not inferred:
20
+ rich.print("[bold red]Error:[/bold red] No repo specified and not in a git directory")
21
+ return 1
22
+ repo = inferred
23
+
24
+ client.delete(f"/user/starred/{repo}", action=f"Unstarring {repo}")
25
+ rich.print(f"[bold green]Unstarred[/bold green] {repo}")
@@ -5,7 +5,7 @@ import rich
5
5
  from rich.markup import escape
6
6
 
7
7
  from codeberg_cli.git import infer_repo
8
- from codeberg_cli.helpers import require_client
8
+ from codeberg_cli.helpers import get_web_base_url, require_client
9
9
  from xclif import Arg, Option, command
10
10
 
11
11
 
@@ -25,7 +25,7 @@ def _(
25
25
  repo = inferred
26
26
 
27
27
  if web:
28
- webbrowser.open(f"https://codeberg.org/{repo}")
28
+ webbrowser.open(f"{get_web_base_url()}/{repo}")
29
29
  return
30
30
 
31
31
  data = client.get(f"/repos/{repo}")
@@ -36,6 +36,7 @@ def _(
36
36
  rich.print(f" [dim]Stars:[/dim] {data.get('stars_count', 0)} [dim]Forks:[/dim] {data.get('forks_count', 0)}")
37
37
  rich.print(f" [dim]Language:[/dim] {data.get('language', 'N/A')}")
38
38
  rich.print(f" [dim]Visibility:[/dim] {'Private' if data.get('private') else 'Public'}")
39
- rich.print(f" [dim]URL:[/dim] https://codeberg.org/{repo}")
39
+ web_url = f"{get_web_base_url()}/{repo}"
40
+ rich.print(f" [dim]URL:[/dim] {web_url}")
40
41
 
41
42
 
@@ -0,0 +1,6 @@
1
+ from xclif import command
2
+
3
+
4
+ @command("user")
5
+ def _() -> None:
6
+ """View user profiles on Codeberg."""
@@ -0,0 +1,26 @@
1
+ from typing import Annotated
2
+
3
+ import rich
4
+ from rich.markup import escape
5
+
6
+ from codeberg_cli.helpers import require_client
7
+ from xclif import Option, command
8
+
9
+
10
+ @command("view")
11
+ def _(
12
+ username: Annotated[str, Option(description="Username (defaults to authenticated user)", name="username")] = "",
13
+ ) -> None:
14
+ """View a user's profile (defaults to the authenticated user)."""
15
+ client = require_client()
16
+
17
+ user = client.get(f"/users/{username}" if username else "/user")
18
+
19
+ rich.print(f"[bold]{user['login']}[/bold]")
20
+ if user.get("full_name"):
21
+ rich.print(escape(user["full_name"]))
22
+ if user.get("email"):
23
+ rich.print(f" [dim]Email:[/dim] {user['email']}")
24
+ rich.print(f" [dim]Repos:[/dim] {user.get('num_repos', 0)} [dim]Stars:[/dim] {user.get('num_stars', 0)} [dim]Followers:[/dim] {user.get('followers_count', 0)} [dim]Following:[/dim] {user.get('following_count', 0)}")
25
+ if user.get("description"):
26
+ rich.print(f" [dim]Bio:[/dim] {escape(user['description'])}")
@@ -0,0 +1,118 @@
1
+ Metadata-Version: 2.4
2
+ Name: codeberg-cli
3
+ Version: 0.2.0
4
+ Summary: A Forgejo CLI — works with Codeberg and any Forgejo instance
5
+ Project-URL: Homepage, https://codeberg.org/ThatXliner/codeberg-cli
6
+ Project-URL: Repository, https://codeberg.org/ThatXliner/codeberg-cli
7
+ Project-URL: Issues, https://codeberg.org/ThatXliner/codeberg-cli/issues
8
+ Author-email: Bryan Hu <thatxliner@gmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: cli,codeberg,forgejo
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Software Development :: Version Control :: Git
19
+ Requires-Python: >=3.12
20
+ Requires-Dist: httpx
21
+ Requires-Dist: platformdirs
22
+ Requires-Dist: tomlkit
23
+ Requires-Dist: xclif>=0.4.3
24
+ Description-Content-Type: text/markdown
25
+
26
+ # cb — A Codeberg CLI
27
+
28
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/codeberg-cli)](https://pypi.org/project/codeberg-cli)
29
+ [![PyPI](https://img.shields.io/pypi/v/codeberg-cli)](https://pypi.org/project/codeberg-cli)
30
+ [![PyPI - License](https://img.shields.io/pypi/l/codeberg-cli)](#license)
31
+
32
+ `cb` is a CLI for [Codeberg](https://codeberg.org) (a [Forgejo](https://forgejo.org) instance) — think `gh` for Codeberg. It also works with any Forgejo instance. Built with [Xclif](https://xclif.readthedocs.io).
33
+
34
+ ```text
35
+ # One-time setup
36
+ cb auth login
37
+
38
+ # Work with repos, issues, PRs, releases
39
+ cb repo list
40
+ cb issue create --title "Fix the thing"
41
+ cb pr create --base main --head fix
42
+ cb release create v0.2.0
43
+ ```
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ pip install codeberg-cli # or: uv tool install codeberg-cli
49
+ ```
50
+
51
+ Or from source:
52
+
53
+ ```bash
54
+ git clone https://codeberg.org/ThatXliner/codeberg-cli.git
55
+ cd cb
56
+ uv tool install .
57
+ ```
58
+
59
+ ## Quickstart
60
+
61
+ ```bash
62
+ # Authenticate (tokens at https://codeberg.org/user/settings/applications)
63
+ cb auth login
64
+
65
+ # Who am I?
66
+ cb auth whoami
67
+
68
+ # List your repos
69
+ cb repo list
70
+
71
+ # Clone one
72
+ cb repo clone ThatXliner/cb
73
+
74
+ # Open an issue
75
+ cb issue create --title "suggestion" --body "what about..."
76
+
77
+ # See everything you can do
78
+ cb --help
79
+ ```
80
+
81
+ ## Config
82
+
83
+ Token stored in `$XDG_CONFIG_HOME/codeberg-cli/config.toml` (managed by `cb auth login` / `cb auth logout`).
84
+
85
+ View or change config:
86
+
87
+ ```bash
88
+ cb config path # Show config file location
89
+ cb config get # Print all config values
90
+ cb config set base_url "https://codeberg.org/api/v1" # Codeberg (default)
91
+ cb config set base_url "https://git.example.com/api/v1" # Self-hosted Forgejo
92
+ ```
93
+
94
+ ## Comparison
95
+
96
+ | | **cb** | **fj** (forgejo-cli) | **berg** (codeberg-cli) |
97
+ |---|---|---|---|
98
+ | Language | Python | Rust | Rust |
99
+ | Multi-instance | Yes (configurable via `cb config set base_url`) | Any Forgejo instance | Yes |
100
+ | Issues | create, list, view, close, reopen, comment, edit | open, edit, comment, close | yes |
101
+ | Pull requests | create, list, view, merge, checkout, close, comment, edit | create, merge | yes |
102
+ | Releases | create, list, view, upload | publish | — |
103
+ | Repos | create, list, clone, view, fork, delete, star, unstar | create, edit, star, watch | yes |
104
+ | Labels | create, list, delete | — | yes |
105
+ | Milestones | create, list | — | yes |
106
+ | Notifications | list | — | yes |
107
+ | User profiles | view | search, keys | — |
108
+ | Raw API | `cb api GET /path` | — | — |
109
+ | Config management | `cb config get/set/path` | — | `berg config` |
110
+ | AGit PRs (no-fork) | — | yes | — |
111
+ | Org/team mgmt | — | yes | — |
112
+ | Install | `pip install codeberg-cli` | prebuilt binaries | `cargo install codeberg-cli` |
113
+
114
+ **Choose `fj`** if you self-host Forgejo or need org/team management or Actions workflows. **Choose `berg`** if you want your tools to be written in Rust. **Choose `cb`** if you want a minimal, readable Python CLI — it's the only one that uploads release assets, the only one with a raw API command, and the most fully-featured for everyday issue/PR/repo workflows. We will be actively working on making ours the most feature complete, just open an issue!
115
+
116
+ ## License
117
+
118
+ MIT
@@ -0,0 +1,59 @@
1
+ codeberg_cli/__main__.py,sha256=35xiBzBbgDy9rcHqCJWHdIVsWhGEtPQNd-s9wt3k7yE,141
2
+ codeberg_cli/client.py,sha256=oObp9d5HN7YZgQ33I6qcnwPGqsk51wr45TQF2qoD5h8,2719
3
+ codeberg_cli/config.py,sha256=bjVHHM1sG3xTIU6EC7sakwR0nnP-ZsCtO4Jn2od6X4A,822
4
+ codeberg_cli/git.py,sha256=DIzHSaezhWQGl63f8pkYDNd-qDfoMe7SZBZ7TWVlCxc,1899
5
+ codeberg_cli/helpers.py,sha256=wa0wAyQbKI5Lb82IBKMisURUTfWJQ_lSPHGMlz1KKrs,1387
6
+ codeberg_cli/routes/__init__.py,sha256=y-lopyOkrIdwAZ5yRo2h_RZWtB1RmNq7bN91WU3uOP0,298
7
+ codeberg_cli/routes/api.py,sha256=o8she7_o32DNuYqr1v9D0hEHjujW1xEZEhHuxIzKO_8,720
8
+ codeberg_cli/routes/auth/__init__.py,sha256=eQHz7mep-kGESlmrv4tTAD3EPTqlNC2Dy9MeJkTxUi0,139
9
+ codeberg_cli/routes/auth/login.py,sha256=6QxaEz5cvSgJbr-dXpudP2VbGxsLx3xPw0cMwWZm1aU,654
10
+ codeberg_cli/routes/auth/logout.py,sha256=LfsYchVxrr77R5c2WNGbKNCLbkyQtbobkhcpw1DH8HA,351
11
+ codeberg_cli/routes/auth/status.py,sha256=jNhIPtcxQMslPhZp2dsWZiPTjzNBWY2ZCTuDSo0Gmvk,638
12
+ codeberg_cli/routes/auth/whoami.py,sha256=Zl5Pr7CKDocUmUMPPixQ9nqpD3zJLffRpG6yQQxAtUc,270
13
+ codeberg_cli/routes/issue/__init__.py,sha256=MTK7_DnakcurjWZydkccaCoLo6lPEiXXJUrsBykleIQ,122
14
+ codeberg_cli/routes/issue/close.py,sha256=RAAJiFPVV4FrNStlviMzUw9ZZjSj-h80sCfG0mZysl0,774
15
+ codeberg_cli/routes/issue/comment.py,sha256=qMkIx6kCq3qXjtiMEhLDTpnNomdEM2RNq0ZHpZANCC8,1326
16
+ codeberg_cli/routes/issue/create.py,sha256=UcOXeGSINLM4FsUSefi5MQOGC4lXDgkv1tT-V8dqTGI,1744
17
+ codeberg_cli/routes/issue/edit.py,sha256=Ctyq0AGAJg-M3S7DfeGuj1LYWx8f7Oem_9wGDFnPVNo,1170
18
+ codeberg_cli/routes/issue/list.py,sha256=bolDAmeI7saZ6AGnWsSOQmRHqgRWm21EP8rKINfXbUI,1502
19
+ codeberg_cli/routes/issue/reopen.py,sha256=QsxWU_1Qed6hdmFs9AiNKk191WkJLeeKInAINeSiiTA,782
20
+ codeberg_cli/routes/issue/view.py,sha256=km1N2Prgi9zxy_ohgwx79W771uR-LoWMbLruOEDNgVM,1533
21
+ codeberg_cli/routes/label/__init__.py,sha256=c3wahlhcqgG02ok34lYR4jmd0Z1KNwkQs_QZNePwVew,128
22
+ codeberg_cli/routes/label/create.py,sha256=Oqlf-xouSJg0nbDc-31e5b_d46d-XzCOXNIc9JVnKcc,1124
23
+ codeberg_cli/routes/label/delete.py,sha256=AGffQbsEwsGwFXtgIQltABWcLREAV1p4kpu4Xzamle4,759
24
+ codeberg_cli/routes/label/list.py,sha256=sVFfQsWkQVi4WqGIP03apabWzPFIgUvHktsRuqugw34,1248
25
+ codeberg_cli/routes/milestone/__init__.py,sha256=Gtqc5vKHpjnbzAZDoPkiCcrbYhby9kpyphqEVFPspI0,116
26
+ codeberg_cli/routes/milestone/create.py,sha256=VDA9q3y8uEDRJ3oIOPn2EWsUwvrNpeOviAXQFSLv7E4,1086
27
+ codeberg_cli/routes/milestone/list.py,sha256=LRoUmHMgamOqMuo085cEoIU9tdPhXduqN5DWd-JFUg8,1514
28
+ codeberg_cli/routes/notification/__init__.py,sha256=_oW9svvLRSiQZNb2NnYigNYvNqDCrfHRd94MDam08Cg,123
29
+ codeberg_cli/routes/notification/list.py,sha256=nprfT22MXZ1Ko6SUahS7hghJ4TwCqXxMJmFEwNAL74M,1082
30
+ codeberg_cli/routes/pr/__init__.py,sha256=QJ-u92u_rnQdsMoLgNhudbiSStmlAO5E-dn0Z4APmds,128
31
+ codeberg_cli/routes/pr/checkout.py,sha256=B5wYePvASjoFp3G1rMbpBjicbe0DPibFxIlMlO7W204,1221
32
+ codeberg_cli/routes/pr/close.py,sha256=Xge_cP9K0prup9CUvvSjuMj_NgF6ZG00SF0Ciets-t0,795
33
+ codeberg_cli/routes/pr/comment.py,sha256=AFjTeKlRi4xsTJTFs0jSklzG6GKN1JmLPWJcBKYee8o,1326
34
+ codeberg_cli/routes/pr/create.py,sha256=X-MlQ6FGqyGD12I0CeFl8Q3beVlyaNDK7f8zGBgRX4Q,2111
35
+ codeberg_cli/routes/pr/edit.py,sha256=saLp8j7gPcDnT2oGrmJWzcSrQgty6z_Nzz3bBaqbmmo,1169
36
+ codeberg_cli/routes/pr/list.py,sha256=bA4C9BN4E9xlmnDTHKkFh-zClLQ2agPU8fWz2lMT6rA,1286
37
+ codeberg_cli/routes/pr/merge.py,sha256=AIP-mm_Fvq0HG7v_vm7eYwtuU61JKLzjJqbFuZqnqgg,915
38
+ codeberg_cli/routes/pr/view.py,sha256=QQygrTIQEkCvCQwW2td4_9E2AfxmgPWcAuiXANR8jgU,1391
39
+ codeberg_cli/routes/release/__init__.py,sha256=GVDrIgpsXz3LCs2ivPnfVOrukYcSalNsRl6Rxradngk,134
40
+ codeberg_cli/routes/release/create.py,sha256=gZYCfzoA7tPBF8L4uvezBPGkgcdRtjcZBdnj3EVtK80,1405
41
+ codeberg_cli/routes/release/list.py,sha256=81mSZCYM0udIEt4j9-JV4o_A3AKUjbKvBg5D5veFqtg,1289
42
+ codeberg_cli/routes/release/upload.py,sha256=rTci3LEpW287PllqN75KOf-Q7BRuTZ-eusTaEiLILd0,1664
43
+ codeberg_cli/routes/release/view.py,sha256=BsM027YGSNBSfPMr6ya6sdNxowc-mvOaiTf-Vn-cgfM,1701
44
+ codeberg_cli/routes/repo/__init__.py,sha256=5mN6Vhtnt11V2hiFstJd2bnFk_Oezslu_XEgPQR0RP0,126
45
+ codeberg_cli/routes/repo/clone.py,sha256=KBdjX251MZfJd4xj_Z7C-XxYZCmuBGSgdLU65f9PnwQ,552
46
+ codeberg_cli/routes/repo/create.py,sha256=NfWaGmXOoHt4SwyjTNLHOW_HYDWgTXuJyeW2eDc2O3Y,2805
47
+ codeberg_cli/routes/repo/delete.py,sha256=ubUotlWGxLZ3MigHIrWd5Y3xV4LVlWdZZxyfVVKEz24,755
48
+ codeberg_cli/routes/repo/fork.py,sha256=qdutue-11POrsN-HM_No2vIIibQB1H4HqBLJcVtks_0,810
49
+ codeberg_cli/routes/repo/list.py,sha256=8Lledw3c_JVmmn3K4CFRxo6kINWErTa6Y2CdIqJ6mH0,1104
50
+ codeberg_cli/routes/repo/star.py,sha256=dwHM--10ehMtaIcI-s7e0NHW97hB9oHEhrwKQQmSEbo,680
51
+ codeberg_cli/routes/repo/unstar.py,sha256=IxDPa0EJKBz0qdTqvDPZOdPVWsSHi9A98Ka73yrExKE,693
52
+ codeberg_cli/routes/repo/view.py,sha256=0WyU_vj-uvkd712xZBmFw9tsLCg0CtupsZe4MYL_K9o,1356
53
+ codeberg_cli/routes/user/__init__.py,sha256=iAE6MlGEhCvzRzYsbgPdlXJ6WDs9Alcs7E0j3g5r8LQ,104
54
+ codeberg_cli/routes/user/view.py,sha256=-5xa2YPguCEaCw_S5n35r568dWFCgt95_uIaEVEE99U,1017
55
+ codeberg_cli-0.2.0.dist-info/METADATA,sha256=oSSDIdPwFIdVpzY3Oz4KtTjzcGv8yurbg1zDk0N1xtg,4148
56
+ codeberg_cli-0.2.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
57
+ codeberg_cli-0.2.0.dist-info/entry_points.txt,sha256=7Kg1K5av7D5TzPCqX_qyFCj09JTneIEvW0f2Gp4q1v4,49
58
+ codeberg_cli-0.2.0.dist-info/licenses/LICENSE,sha256=o71itnX05JiF5qOrKHEmWIvuf03sgYcwsc3r6AAW_h0,1065
59
+ codeberg_cli-0.2.0.dist-info/RECORD,,
@@ -1,182 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: codeberg-cli
3
- Version: 0.1.0
4
- Summary: A Codeberg CLI
5
- Project-URL: Homepage, https://codeberg.org/ThatXliner/codeberg-cli
6
- Project-URL: Repository, https://codeberg.org/ThatXliner/codeberg-cli
7
- Project-URL: Issues, https://codeberg.org/ThatXliner/codeberg-cli/issues
8
- Author-email: Bryan Hu <thatxliner@gmail.com>
9
- License-Expression: MIT
10
- License-File: LICENSE
11
- Keywords: cli,codeberg,forgejo
12
- Classifier: Development Status :: 4 - Beta
13
- Classifier: Environment :: Console
14
- Classifier: Intended Audience :: Developers
15
- Classifier: License :: OSI Approved :: MIT License
16
- Classifier: Programming Language :: Python :: 3.12
17
- Classifier: Programming Language :: Python :: 3.13
18
- Classifier: Topic :: Software Development :: Version Control :: Git
19
- Requires-Python: >=3.12
20
- Requires-Dist: httpx
21
- Requires-Dist: platformdirs
22
- Requires-Dist: tomlkit
23
- Requires-Dist: xclif>=0.4.3
24
- Description-Content-Type: text/markdown
25
-
26
- # cb — A Codeberg CLI
27
-
28
- [![CI](https://github.com/ThatXliner/cb/actions/workflows/ci.yml/badge.svg)](https://github.com/ThatXliner/cb/actions/workflows/ci.yml) [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/codeberg-cli)](https://pypi.org/project/codeberg-cli)
29
- [![PyPI](https://img.shields.io/pypi/v/codeberg-cli)](https://pypi.org/project/codeberg-cli)
30
- [![PyPI - License](https://img.shields.io/pypi/l/codeberg-cli)](#license)
31
-
32
- `cb` is a native CLI for [Codeberg](https://codeberg.org) (a [Forgejo](https://forgejo.org) instance) — think `gh` for Codeberg. Built with [Xclif](https://xclif.readthedocs.io).
33
-
34
- ```text
35
- # One-time setup
36
- cb auth login
37
-
38
- # Work with repos, issues, PRs, releases
39
- cb repo list
40
- cb issue create --title "Fix the thing"
41
- cb pr create --base main --head fix
42
- cb release create v0.2.0
43
- ```
44
-
45
- ## Install
46
-
47
- ```bash
48
- pip install codeberg-cli # or: uv tool install codeberg-cli
49
- ```
50
-
51
- Or from source:
52
-
53
- ```bash
54
- git clone https://codeberg.org/ThatXliner/codeberg-cli
55
- cd cb
56
- uv tool install .
57
- ```
58
-
59
- ## Quickstart
60
-
61
- ```bash
62
- # Authenticate (tokens at https://codeberg.org/user/settings/applications)
63
- cb auth login
64
-
65
- # Who am I?
66
- cb auth whoami
67
-
68
- # List your repos
69
- cb repo list
70
-
71
- # Clone one
72
- cb repo clone ThatXliner/cb
73
-
74
- # Open an issue
75
- cb issue create --title "suggestion" --body "what about..."
76
- ```
77
-
78
- ## Commands
79
-
80
- ### `cb auth` — authentication
81
-
82
- | Command | Description |
83
- |---------|-------------|
84
- | `login` | Store a Codeberg access token |
85
- | `logout` | Remove stored credentials |
86
- | `status` | Show login state |
87
- | `whoami` | Print current username |
88
-
89
- ### `cb repo` — repositories
90
-
91
- Infer `owner/repo` from `git remote origin`. Override with `--repo`.
92
-
93
- | Command | Description |
94
- |---------|-------------|
95
- | `create` | Create a repo (`--org`, `--private`, `--description`) |
96
- | `list` | List repos for a user or org |
97
- | `clone` | Clone via `git clone` |
98
- | `view` | Show repo details (`--web` to open browser) |
99
- | `fork` | Fork a repo |
100
- | `delete` | Delete a repo (requires confirmation) |
101
-
102
- ### `cb label` — labels
103
-
104
- | Command | Description |
105
- |---------|-------------|
106
- | `list` (alias: `ls`) | List repo labels |
107
- | `create` | Create a label (`--color`, `--description`) |
108
- | `delete` | Delete a label by ID |
109
-
110
- ### `cb milestone` — milestones
111
-
112
- | Command | Description |
113
- |---------|-------------|
114
- | `list` (alias: `ls`) | List milestones (`--state`) |
115
- | `create` | Create a milestone (`--description`, `--due-on`) |
116
-
117
- ### `cb issue` — issues
118
-
119
- | Command | Description |
120
- |---------|-------------|
121
- | `create` | Create an issue (`--labels`, omit `--body` to open stdin) |
122
- | `list` | List issues (`--state`, `--label`, `--limit`) |
123
- | `view` | View an issue with comments (`--web`) |
124
- | `close` | Close an issue |
125
- | `reopen` | Reopen a closed issue |
126
-
127
- ### `cb pr` — pull requests
128
-
129
- | Command | Description |
130
- |---------|-------------|
131
- | `create` | Open a PR (omit `--body` to open `$EDITOR`) |
132
- | `list` | List PRs (alias: `ls`) |
133
- | `view` | View a PR (`--web`) |
134
- | `merge` | Merge with `--style merge|rebase|squash` |
135
- | `checkout` | Fetch and checkout a PR locally (alias: `co`) |
136
- | `close` | Close without merging |
137
-
138
- ### `cb release` — releases
139
-
140
- | Command | Description |
141
- |---------|-------------|
142
- | `create` | Tag a release (`--prerelease`, `--draft`) |
143
- | `list` | List releases |
144
- | `view` | View a release (`--web`) |
145
- | `upload` | Attach a file to a release |
146
-
147
- ### `cb api` — raw API access
148
-
149
- ```bash
150
- cb api GET /version
151
- cb api POST /repos/owner/repo/issues --data '{"title": "hi"}'
152
- ```
153
-
154
- ## Config
155
-
156
- Token stored in `$XDG_CONFIG_HOME/cb/config.toml` (managed by `cb auth login` / `cb auth logout`).
157
-
158
- ## Comparison
159
-
160
- There are two other CLI tools you can use with Codeberg:
161
-
162
- | | **cb** | **fj** (forgejo-cli) | **berg** (codeberg-cli) |
163
- |---|---|---|---|
164
- | Language | Python | Rust | Rust |
165
- | Codeberg-native | Yes (targets `codeberg.org/api/v1`) | Generic (any Forgejo instance) | Yes |
166
- | Issues | create, list, view, close, reopen | open, edit, comment, close | yes |
167
- | Pull requests | create, list, view, merge, checkout, close | create, merge | yes |
168
- | Releases | create, list, view, upload | publish | — |
169
- | Repos | create, list, clone, view, fork, delete | create, edit, star, watch | yes |
170
- | Labels | create, list, delete | — | yes |
171
- | Milestones | create, list | — | yes |
172
- | Raw API | `cb api GET /path` | — | — |
173
- | AGit PRs (no-fork) | — | yes | — |
174
- | Org/team mgmt | — | yes | — |
175
- | Install | `pip install codeberg-cli` | prebuilt binaries | `cargo install codeberg-cli` |
176
- | Size | ~300 lines | Rust binary | Rust binary |
177
-
178
- **Choose `fj`** if you self-host Forgejo or need org/team management. **Choose `berg`** if you want labels and milestones from a Rust binary. **Choose `cb`** if you want a minimal, readable Python CLI with release management and raw API access — `cb` is also the only one that uploads release assets.
179
-
180
- ## License
181
-
182
- MIT
@@ -1,49 +0,0 @@
1
- codeberg_cli/__main__.py,sha256=ysV0kHP-Q-sUwHOInLcB-QEryJfNNv-amdHP0EubG-8,113
2
- codeberg_cli/client.py,sha256=wqf5mOR9Uyku7v_GZRjFQUZsYAfqiWXcJv_DMmubQWU,2242
3
- codeberg_cli/config.py,sha256=bjVHHM1sG3xTIU6EC7sakwR0nnP-ZsCtO4Jn2od6X4A,822
4
- codeberg_cli/git.py,sha256=oWOU8iV4FUbtZLA7RvDqRur6whYDoJKoIcUPtBxJZto,1544
5
- codeberg_cli/helpers.py,sha256=0JGASGM_td_q2l9YOF1KybisZFofKeT5jC4q-G_lS48,627
6
- codeberg_cli/routes/__init__.py,sha256=LPAm_RsDHeREJ-57n8CwFAdkXkTcykx9zjrKhQZFuvI,86
7
- codeberg_cli/routes/api.py,sha256=o8she7_o32DNuYqr1v9D0hEHjujW1xEZEhHuxIzKO_8,720
8
- codeberg_cli/routes/auth/__init__.py,sha256=TxwcK78EFqRSsHs2iSVRQ8Og4upvq91P3fcKcOFpbFQ,41
9
- codeberg_cli/routes/auth/login.py,sha256=jcarfwPedC2xWqbs-ncNNZXI_5Kv4mdHt7IQa3azAqg,656
10
- codeberg_cli/routes/auth/logout.py,sha256=LfsYchVxrr77R5c2WNGbKNCLbkyQtbobkhcpw1DH8HA,351
11
- codeberg_cli/routes/auth/status.py,sha256=fkMgFiwEOBNNNDkJ4S0ahx9epfRQZzXVdYu6KEI4oQI,640
12
- codeberg_cli/routes/auth/whoami.py,sha256=RjXbklkr0ajFX3BT9B8k1Qr6zSooaZWmQ-9oqjFcshs,272
13
- codeberg_cli/routes/issue/__init__.py,sha256=RRWKFz7LRRGD3shDlBXRdLSO6IebeLLlzdjY3qRCrqQ,42
14
- codeberg_cli/routes/issue/close.py,sha256=RAAJiFPVV4FrNStlviMzUw9ZZjSj-h80sCfG0mZysl0,774
15
- codeberg_cli/routes/issue/create.py,sha256=UW-ilstUjQ2Lu16UZJOjBH8eCMNO3DDF0dPSR-0PE3g,1726
16
- codeberg_cli/routes/issue/list.py,sha256=bolDAmeI7saZ6AGnWsSOQmRHqgRWm21EP8rKINfXbUI,1502
17
- codeberg_cli/routes/issue/reopen.py,sha256=QsxWU_1Qed6hdmFs9AiNKk191WkJLeeKInAINeSiiTA,782
18
- codeberg_cli/routes/issue/view.py,sha256=7pU0vJmgGiaRFDQ_MDESOtPVu9SEoWVH8piN9bzClYo,1515
19
- codeberg_cli/routes/label/__init__.py,sha256=7yTJhxfd1ErLUoYJVKKhtbmSmC6kwIbUwNcIsu7dAas,42
20
- codeberg_cli/routes/label/create.py,sha256=Oqlf-xouSJg0nbDc-31e5b_d46d-XzCOXNIc9JVnKcc,1124
21
- codeberg_cli/routes/label/delete.py,sha256=AGffQbsEwsGwFXtgIQltABWcLREAV1p4kpu4Xzamle4,759
22
- codeberg_cli/routes/label/list.py,sha256=sVFfQsWkQVi4WqGIP03apabWzPFIgUvHktsRuqugw34,1248
23
- codeberg_cli/routes/milestone/__init__.py,sha256=0LihqWaCxPtc9yYPmXsorKagjKt065qsnPNfnO2MGAY,46
24
- codeberg_cli/routes/milestone/create.py,sha256=VDA9q3y8uEDRJ3oIOPn2EWsUwvrNpeOviAXQFSLv7E4,1086
25
- codeberg_cli/routes/milestone/list.py,sha256=LRoUmHMgamOqMuo085cEoIU9tdPhXduqN5DWd-JFUg8,1514
26
- codeberg_cli/routes/pr/__init__.py,sha256=8WsMUvMJmcd5BJaSfMXVIaTNkEnrgFyCEGynl2VKKZI,39
27
- codeberg_cli/routes/pr/checkout.py,sha256=B5wYePvASjoFp3G1rMbpBjicbe0DPibFxIlMlO7W204,1221
28
- codeberg_cli/routes/pr/close.py,sha256=Xge_cP9K0prup9CUvvSjuMj_NgF6ZG00SF0Ciets-t0,795
29
- codeberg_cli/routes/pr/create.py,sha256=VT5Xu_BQQKsgzZjKnNeVHq7jsQKbf6tMUO_1Rgufa6U,2093
30
- codeberg_cli/routes/pr/list.py,sha256=bA4C9BN4E9xlmnDTHKkFh-zClLQ2agPU8fWz2lMT6rA,1286
31
- codeberg_cli/routes/pr/merge.py,sha256=AIP-mm_Fvq0HG7v_vm7eYwtuU61JKLzjJqbFuZqnqgg,915
32
- codeberg_cli/routes/pr/view.py,sha256=uaIGzGtMY21K9SAF2qRN58lLpMLQ2Dw1D19nbtke6gw,1373
33
- codeberg_cli/routes/release/__init__.py,sha256=4NdEUIvolvOMJ8XFP-GkPleiTCo0xvf6JIcXbUf25kE,44
34
- codeberg_cli/routes/release/create.py,sha256=nFTJCWEAc2S_lAoi-moeQqi4b6l_Q10FDdZYcMa24DQ,1387
35
- codeberg_cli/routes/release/list.py,sha256=81mSZCYM0udIEt4j9-JV4o_A3AKUjbKvBg5D5veFqtg,1289
36
- codeberg_cli/routes/release/upload.py,sha256=QG_jmUTamOsGajRVhtZ8N7H0_oMY_DWiIMobBkSeu3g,1674
37
- codeberg_cli/routes/release/view.py,sha256=o9I1I9nkJjTcqMfxQdtXbTITZWxVYGiwmL6JrVPyg84,1683
38
- codeberg_cli/routes/repo/__init__.py,sha256=UB56y2Kc_0altG6JrTvSYb5cFunqeQsDMdu5d4q_ROc,41
39
- codeberg_cli/routes/repo/clone.py,sha256=mFk3i4Qf5MZObh_bcQniO36FuNNBJNWZQhB9QR_4lSI,511
40
- codeberg_cli/routes/repo/create.py,sha256=XUw3TQjhwB6oL0FnshnBVEKvjSQiWTs8fUOZHh-EkwY,2803
41
- codeberg_cli/routes/repo/delete.py,sha256=ubUotlWGxLZ3MigHIrWd5Y3xV4LVlWdZZxyfVVKEz24,755
42
- codeberg_cli/routes/repo/fork.py,sha256=ApVdzAFXetNQJ8R9WUk-gejB2BDJ11cTFb3pn0V5MBg,810
43
- codeberg_cli/routes/repo/list.py,sha256=8Lledw3c_JVmmn3K4CFRxo6kINWErTa6Y2CdIqJ6mH0,1104
44
- codeberg_cli/routes/repo/view.py,sha256=ypJPFPsQKiQ1xw8LFZ04PFWNcP7N_ce9J5XWD2wlMXo,1311
45
- codeberg_cli-0.1.0.dist-info/METADATA,sha256=zMqXfnXqXzE2UwiUgnO6ukwF_PLNNL7xyXT3NO3y5Is,5812
46
- codeberg_cli-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
47
- codeberg_cli-0.1.0.dist-info/entry_points.txt,sha256=7Kg1K5av7D5TzPCqX_qyFCj09JTneIEvW0f2Gp4q1v4,49
48
- codeberg_cli-0.1.0.dist-info/licenses/LICENSE,sha256=o71itnX05JiF5qOrKHEmWIvuf03sgYcwsc3r6AAW_h0,1065
49
- codeberg_cli-0.1.0.dist-info/RECORD,,