codeberg-cli 0.4.1__py3-none-any.whl → 0.5.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 (54) hide show
  1. codeberg_cli/client.py +19 -4
  2. codeberg_cli/routes/actions/_format.py +24 -1
  3. codeberg_cli/routes/actions/dispatch.py +16 -5
  4. codeberg_cli/routes/actions/run.py +12 -1
  5. codeberg_cli/routes/actions/runs.py +5 -2
  6. codeberg_cli/routes/actions/workflows.py +1 -1
  7. codeberg_cli/routes/issue/assets/__init__.py +6 -0
  8. codeberg_cli/routes/issue/assets/delete.py +27 -0
  9. codeberg_cli/routes/issue/assets/list.py +47 -0
  10. codeberg_cli/routes/issue/assets/upload.py +50 -0
  11. codeberg_cli/routes/issue/deps/__init__.py +6 -0
  12. codeberg_cli/routes/issue/deps/add.py +27 -0
  13. codeberg_cli/routes/issue/deps/list.py +41 -0
  14. codeberg_cli/routes/issue/deps/remove.py +36 -0
  15. codeberg_cli/routes/issue/react.py +40 -0
  16. codeberg_cli/routes/issue/reactions.py +39 -0
  17. codeberg_cli/routes/keys/__init__.py +6 -0
  18. codeberg_cli/routes/keys/gpg/__init__.py +6 -0
  19. codeberg_cli/routes/keys/gpg/add.py +31 -0
  20. codeberg_cli/routes/keys/gpg/delete.py +17 -0
  21. codeberg_cli/routes/keys/gpg/list.py +29 -0
  22. codeberg_cli/routes/keys/ssh/__init__.py +6 -0
  23. codeberg_cli/routes/keys/ssh/add.py +31 -0
  24. codeberg_cli/routes/keys/ssh/delete.py +17 -0
  25. codeberg_cli/routes/keys/ssh/list.py +29 -0
  26. codeberg_cli/routes/notify/__init__.py +6 -0
  27. codeberg_cli/routes/{notifications.py → notify/list.py} +4 -2
  28. codeberg_cli/routes/notify/read.py +23 -0
  29. codeberg_cli/routes/notify/thread.py +31 -0
  30. codeberg_cli/routes/pr/review.py +44 -0
  31. codeberg_cli/routes/pr/reviews.py +48 -0
  32. codeberg_cli/routes/pr/status.py +51 -0
  33. codeberg_cli/routes/repo/hook/__init__.py +6 -0
  34. codeberg_cli/routes/repo/hook/create.py +46 -0
  35. codeberg_cli/routes/repo/hook/delete.py +26 -0
  36. codeberg_cli/routes/repo/hook/list.py +42 -0
  37. codeberg_cli/routes/repo/hook/test.py +26 -0
  38. codeberg_cli/routes/repo/hook/view.py +35 -0
  39. codeberg_cli/routes/repo/mirror/__init__.py +6 -0
  40. codeberg_cli/routes/repo/mirror/create.py +47 -0
  41. codeberg_cli/routes/repo/mirror/delete.py +26 -0
  42. codeberg_cli/routes/repo/mirror/list.py +47 -0
  43. codeberg_cli/routes/repo/mirror/sync.py +25 -0
  44. codeberg_cli/routes/repo/wiki/__init__.py +6 -0
  45. codeberg_cli/routes/repo/wiki/create.py +34 -0
  46. codeberg_cli/routes/repo/wiki/delete.py +27 -0
  47. codeberg_cli/routes/repo/wiki/edit.py +39 -0
  48. codeberg_cli/routes/repo/wiki/list.py +42 -0
  49. codeberg_cli/routes/repo/wiki/view.py +35 -0
  50. {codeberg_cli-0.4.1.dist-info → codeberg_cli-0.5.0.dist-info}/METADATA +15 -15
  51. {codeberg_cli-0.4.1.dist-info → codeberg_cli-0.5.0.dist-info}/RECORD +54 -12
  52. {codeberg_cli-0.4.1.dist-info → codeberg_cli-0.5.0.dist-info}/WHEEL +0 -0
  53. {codeberg_cli-0.4.1.dist-info → codeberg_cli-0.5.0.dist-info}/entry_points.txt +0 -0
  54. {codeberg_cli-0.4.1.dist-info → codeberg_cli-0.5.0.dist-info}/licenses/LICENSE +0 -0
codeberg_cli/client.py CHANGED
@@ -16,6 +16,10 @@ _VERBS = {
16
16
  class ClientError(RuntimeError):
17
17
  """Raised when the API returns a non-2xx status."""
18
18
 
19
+ def __init__(self, message: str, status_code: int | None = None) -> None:
20
+ super().__init__(message)
21
+ self.status_code = status_code
22
+
19
23
 
20
24
  def _response_error_message(response: httpx.Response) -> str:
21
25
  if not response.text:
@@ -52,7 +56,12 @@ class Client:
52
56
  raw = raw.rstrip("/")
53
57
  if not raw.endswith("/api/v1"):
54
58
  raw += "/api/v1"
55
- self._client = httpx.Client(base_url=raw, headers=headers, timeout=30.0)
59
+ self._client = httpx.Client(
60
+ base_url=raw,
61
+ headers=headers,
62
+ timeout=30.0,
63
+ follow_redirects=True,
64
+ )
56
65
 
57
66
  @property
58
67
  def base_url(self) -> str:
@@ -75,9 +84,13 @@ class Client:
75
84
  return self._request("PATCH", path, json=data, action=action)
76
85
 
77
86
  def put(
78
- self, path: str, data: dict | None = None, action: str | None = None
87
+ self,
88
+ path: str,
89
+ data: dict | None = None,
90
+ params: dict | None = None,
91
+ action: str | None = None,
79
92
  ) -> dict | list:
80
- return self._request("PUT", path, json=data, action=action)
93
+ return self._request("PUT", path, json=data, params=params, action=action)
81
94
 
82
95
  def delete(self, path: str, action: str | None = None) -> None:
83
96
  self._request("DELETE", path, action=action)
@@ -99,7 +112,9 @@ class Client:
99
112
  response = self._client.request(method, path, **kwargs)
100
113
  if not response.is_success:
101
114
  msg = _response_error_message(response)
102
- raise ClientError(f"{response.status_code} {msg}")
115
+ raise ClientError(
116
+ f"{response.status_code} {msg}", status_code=response.status_code
117
+ )
103
118
  if response.status_code == 204:
104
119
  return None
105
120
  return response.json()
@@ -1,4 +1,27 @@
1
- from typing import Any
1
+ from typing import Any, Callable, TypeVar
2
+
3
+ from codeberg_cli.client import ClientError
4
+ from xclif.errors import UsageError
5
+
6
+ T = TypeVar("T")
7
+
8
+
9
+ def actions_request(repo: str, call: Callable[[], T]) -> T:
10
+ """Run an Actions API *call*, turning a 404 into a clear message.
11
+
12
+ Forgejo returns 404 for Actions endpoints when Actions is not enabled on
13
+ the repository (or the repo does not exist), which is otherwise surfaced as
14
+ an opaque ``404 The target couldn't be found.`` traceback.
15
+ """
16
+ try:
17
+ return call()
18
+ except ClientError as exc:
19
+ if exc.status_code == 404:
20
+ raise UsageError(
21
+ f"No Actions found for {repo}.",
22
+ hint="Actions may be disabled for this repository, or the repository may not exist.",
23
+ ) from exc
24
+ raise
2
25
 
3
26
 
4
27
  def _first(run: dict[str, Any], *keys: str, default: str = "") -> Any:
@@ -23,9 +23,20 @@ def _(
23
23
  return 1
24
24
  repo = inferred
25
25
 
26
- client.post(
27
- f"/repos/{repo}/actions/workflows/{workflow}/dispatches",
28
- data={"ref": ref},
29
- action="Dispatching workflow",
30
- )
26
+ from codeberg_cli.client import ClientError
27
+ from xclif.errors import UsageError
28
+
29
+ try:
30
+ client.post(
31
+ f"/repos/{repo}/actions/workflows/{workflow}/dispatches",
32
+ data={"ref": ref},
33
+ action="Dispatching workflow",
34
+ )
35
+ except ClientError as exc:
36
+ if exc.status_code == 404:
37
+ raise UsageError(
38
+ f"No workflow {workflow!r} found in {repo}.",
39
+ hint="List workflows with 'cb actions workflows', or Actions may be disabled for this repository.",
40
+ ) from exc
41
+ raise
31
42
  rich.print(f"[green]Dispatched {workflow} on {ref} in {repo}.[/green]")
@@ -24,7 +24,18 @@ def _(
24
24
  return 1
25
25
  repo = inferred
26
26
 
27
- run = client.get(f"/repos/{repo}/actions/runs/{run_id}")
27
+ from codeberg_cli.client import ClientError
28
+ from xclif.errors import UsageError
29
+
30
+ try:
31
+ run = client.get(f"/repos/{repo}/actions/runs/{run_id}")
32
+ except ClientError as exc:
33
+ if exc.status_code == 404:
34
+ raise UsageError(
35
+ f"No run #{run_id} found in {repo}.",
36
+ hint="Check the run ID with 'cb actions runs', or Actions may be disabled for this repository.",
37
+ ) from exc
38
+ raise
28
39
 
29
40
  if is_json_mode():
30
41
  output(run)
@@ -6,7 +6,7 @@ from codeberg_cli.git import infer_repo
6
6
  from codeberg_cli.helpers import print_table, require_client
7
7
  from xclif import Option, command
8
8
 
9
- from codeberg_cli.routes.actions._format import list_runs, normalize_run
9
+ from codeberg_cli.routes.actions._format import actions_request, list_runs, normalize_run
10
10
 
11
11
 
12
12
  @command("runs")
@@ -24,7 +24,10 @@ def _(
24
24
  return 1
25
25
  repo = inferred
26
26
 
27
- response = client.get(f"/repos/{repo}/actions/runs", params={"limit": limit, "page": 1})
27
+ response = actions_request(
28
+ repo,
29
+ lambda: client.get(f"/repos/{repo}/actions/runs", params={"limit": limit, "page": 1}),
30
+ )
28
31
  runs = list_runs(response)
29
32
 
30
33
  if not runs:
@@ -34,7 +34,7 @@ def _(
34
34
  action="Fetching workflow files",
35
35
  )
36
36
  except ClientError as exc:
37
- if str(exc).startswith("404 "):
37
+ if exc.status_code == 404:
38
38
  rich.print(f"[dim]No workflows in {repo}.[/dim]")
39
39
  return
40
40
  raise
@@ -0,0 +1,6 @@
1
+ from xclif import command
2
+
3
+
4
+ @command("assets")
5
+ def _() -> None:
6
+ """Manage issue attachments."""
@@ -0,0 +1,27 @@
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("delete")
11
+ def _(
12
+ index: Annotated[int, Arg(description="Issue number")],
13
+ attachment_id: Annotated[int, Arg(description="Attachment ID")],
14
+ repo: Annotated[str, Option(description="Repository (owner/repo)", name="repo")] = "",
15
+ ) -> None:
16
+ """Delete an issue attachment."""
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
+ client.delete(f"/repos/{repo}/issues/{index}/assets/{attachment_id}")
27
+ rich.print(f"[bold green]Deleted[/bold green] attachment #{attachment_id}")
@@ -0,0 +1,47 @@
1
+ from typing import Annotated
2
+
3
+ import rich
4
+
5
+ from codeberg_cli.git import infer_repo
6
+ from codeberg_cli.helpers import print_table, require_client
7
+ from xclif import Arg, Option, command
8
+
9
+
10
+ @command("list", "ls")
11
+ def _(
12
+ index: Annotated[int, Arg(description="Issue number")],
13
+ repo: Annotated[str, Option(description="Repository (owner/repo)", name="repo")] = "",
14
+ ) -> None:
15
+ """List issue attachments."""
16
+ client = require_client()
17
+
18
+ if not repo:
19
+ inferred = infer_repo()
20
+ if not inferred:
21
+ rich.print("[bold red]Error:[/bold red] No repo specified and not in a git directory")
22
+ return 1
23
+ repo = inferred
24
+
25
+ assets = client.get(f"/repos/{repo}/issues/{index}/assets")
26
+
27
+ if not assets:
28
+ rich.print("[dim]No attachments.[/dim]")
29
+ return
30
+
31
+ rows = []
32
+ json_rows = []
33
+ for asset in assets:
34
+ rows.append((
35
+ str(asset["id"]),
36
+ asset["name"],
37
+ str(asset.get("size", "")),
38
+ asset.get("browser_download_url", ""),
39
+ ))
40
+ json_rows.append({
41
+ "id": asset["id"],
42
+ "name": asset["name"],
43
+ "size": asset.get("size"),
44
+ "browser_download_url": asset.get("browser_download_url"),
45
+ })
46
+
47
+ print_table(["ID", "Name", "Size", "Download"], rows, json_data=json_rows)
@@ -0,0 +1,50 @@
1
+ from pathlib import Path
2
+ from typing import Annotated
3
+
4
+ import httpx
5
+ import rich
6
+
7
+ from codeberg_cli.git import infer_repo
8
+ from codeberg_cli.helpers import require_client
9
+ from xclif import Arg, Option, command
10
+
11
+
12
+ @command("upload")
13
+ def _(
14
+ index: Annotated[int, Arg(description="Issue number")],
15
+ file: Annotated[str, Arg(description="File to upload")],
16
+ repo: Annotated[str, Option(description="Repository (owner/repo)", name="repo")] = "",
17
+ ) -> None:
18
+ """Upload an issue attachment."""
19
+ client = require_client()
20
+
21
+ if not repo:
22
+ inferred = infer_repo()
23
+ if not inferred:
24
+ rich.print("[bold red]Error:[/bold red] No repo specified and not in a git directory")
25
+ return 1
26
+ repo = inferred
27
+
28
+ file_path = Path(file)
29
+ if not file_path.exists():
30
+ rich.print(f"[bold red]Error:[/bold red] File not found: {file}")
31
+ return 1
32
+
33
+ with open(file_path, "rb") as f:
34
+ content = f.read()
35
+
36
+ # Direct multipart upload (bypasses Client for multipart support)
37
+ token = client._client.headers.get("Authorization", "").replace("Bearer ", "")
38
+ headers = {"Authorization": f"Bearer {token}"}
39
+
40
+ response = httpx.post(
41
+ f"{client.base_url}/repos/{repo}/issues/{index}/assets",
42
+ headers=headers,
43
+ files={"attachment": (file_path.name, content)},
44
+ )
45
+ if response.is_success:
46
+ rich.print(f"[bold green]Uploaded[/bold green] {file_path.name} to issue #{index}")
47
+ else:
48
+ msg = response.json().get("message", "unknown error") if response.text else "unknown error"
49
+ rich.print(f"[bold red]Error:[/bold red] {response.status_code} {msg}")
50
+ return 1
@@ -0,0 +1,6 @@
1
+ from xclif import command
2
+
3
+
4
+ @command("deps")
5
+ def _() -> None:
6
+ """Manage issue dependencies (blocked-by)."""
@@ -0,0 +1,27 @@
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("add")
11
+ def _(
12
+ index: Annotated[int, Arg(description="Issue number")],
13
+ depends_on: Annotated[int, Arg(description="Issue number this one depends on")],
14
+ repo: Annotated[str, Option(description="Repository (owner/repo)", name="repo")] = "",
15
+ ) -> None:
16
+ """Add a dependency to 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
+ client.post(f"/repos/{repo}/issues/{index}/dependencies", data={"index": depends_on})
27
+ rich.print(f"[bold green]Issue[/bold green] #{index} now depends on #{depends_on}")
@@ -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 print_table, require_client
7
+ from xclif import Arg, Option, command
8
+
9
+
10
+ @command("list", "ls")
11
+ def _(
12
+ index: Annotated[int, Arg(description="Issue number")],
13
+ repo: Annotated[str, Option(description="Repository (owner/repo)", name="repo")] = "",
14
+ ) -> None:
15
+ """List issue dependencies."""
16
+ client = require_client()
17
+
18
+ if not repo:
19
+ inferred = infer_repo()
20
+ if not inferred:
21
+ rich.print("[bold red]Error:[/bold red] No repo specified and not in a git directory")
22
+ return 1
23
+ repo = inferred
24
+
25
+ deps = client.get(f"/repos/{repo}/issues/{index}/dependencies")
26
+
27
+ if not deps:
28
+ rich.print("[dim]No dependencies.[/dim]")
29
+ return
30
+
31
+ rows = []
32
+ json_rows = []
33
+ for dep in deps:
34
+ rows.append((str(dep["number"]), dep["title"], dep.get("state", "")))
35
+ json_rows.append({
36
+ "number": dep["number"],
37
+ "title": dep["title"],
38
+ "state": dep.get("state"),
39
+ })
40
+
41
+ print_table(["#", "Title", "State"], rows, json_data=json_rows)
@@ -0,0 +1,36 @@
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("remove")
11
+ def _(
12
+ index: Annotated[int, Arg(description="Issue number")],
13
+ depends_on: Annotated[int, Arg(description="Issue number this one depends on")],
14
+ repo: Annotated[str, Option(description="Repository (owner/repo)", name="repo")] = "",
15
+ ) -> None:
16
+ """Remove a dependency from 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
+ response = client._client.request(
27
+ "DELETE",
28
+ f"/repos/{repo}/issues/{index}/dependencies",
29
+ json={"index": depends_on},
30
+ )
31
+ if not response.is_success:
32
+ msg = response.json().get("message", "unknown error") if response.text else "unknown error"
33
+ rich.print(f"[bold red]Error:[/bold red] {response.status_code} {msg}")
34
+ return 1
35
+
36
+ rich.print(f"[bold green]Removed[/bold green] dependency #{depends_on} from issue #{index}")
@@ -0,0 +1,40 @@
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("react")
11
+ def _(
12
+ index: Annotated[int, Arg(description="Issue number")],
13
+ content: Annotated[str, Arg(description="Reaction emoji, e.g. +1, -1, heart, laugh")],
14
+ remove: Annotated[bool, Option(description="Remove the reaction instead of adding it", name="remove")] = False,
15
+ repo: Annotated[str, Option(description="Repository (owner/repo)", name="repo")] = "",
16
+ ) -> None:
17
+ """Add or remove a reaction on an issue."""
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
+ if remove:
28
+ response = client._client.request(
29
+ "DELETE",
30
+ f"/repos/{repo}/issues/{index}/reactions",
31
+ json={"content": content},
32
+ )
33
+ if not response.is_success:
34
+ msg = response.json().get("message", "unknown error") if response.text else "unknown error"
35
+ rich.print(f"[bold red]Error:[/bold red] {response.status_code} {msg}")
36
+ return 1
37
+ rich.print(f"[bold green]Removed[/bold green] reaction :{content}:")
38
+ else:
39
+ client.post(f"/repos/{repo}/issues/{index}/reactions", data={"content": content})
40
+ rich.print(f"[bold green]Added[/bold green] reaction :{content}:")
@@ -0,0 +1,39 @@
1
+ from typing import Annotated
2
+
3
+ import rich
4
+
5
+ from codeberg_cli.git import infer_repo
6
+ from codeberg_cli.helpers import print_table, require_client
7
+ from xclif import Arg, Option, command
8
+
9
+
10
+ @command("reactions")
11
+ def _(
12
+ index: Annotated[int, Arg(description="Issue number")],
13
+ repo: Annotated[str, Option(description="Repository (owner/repo)", name="repo")] = "",
14
+ ) -> None:
15
+ """List reactions on an issue."""
16
+ client = require_client()
17
+
18
+ if not repo:
19
+ inferred = infer_repo()
20
+ if not inferred:
21
+ rich.print("[bold red]Error:[/bold red] No repo specified and not in a git directory")
22
+ return 1
23
+ repo = inferred
24
+
25
+ reactions = client.get(f"/repos/{repo}/issues/{index}/reactions")
26
+
27
+ if not reactions:
28
+ rich.print("[dim]No reactions.[/dim]")
29
+ return
30
+
31
+ rows = []
32
+ json_rows = []
33
+ for reaction in reactions:
34
+ user = (reaction.get("user") or {}).get("login", "")
35
+ content = reaction.get("content", "")
36
+ rows.append((user, content))
37
+ json_rows.append({"user": user, "content": content})
38
+
39
+ print_table(["User", "Reaction"], rows, json_data=json_rows)
@@ -0,0 +1,6 @@
1
+ from xclif import command
2
+
3
+
4
+ @command("keys")
5
+ def _() -> None:
6
+ """Manage your SSH and GPG keys."""
@@ -0,0 +1,6 @@
1
+ from xclif import command
2
+
3
+
4
+ @command("gpg")
5
+ def _() -> None:
6
+ """Manage GPG keys."""
@@ -0,0 +1,31 @@
1
+ from pathlib import Path
2
+ from typing import Annotated
3
+
4
+ import rich
5
+
6
+ from codeberg_cli.helpers import require_client
7
+ from xclif import Arg, Option, command
8
+
9
+
10
+ @command("add")
11
+ def _(
12
+ armored_key: Annotated[str, Option(description="The armored public GPG key", name="armored-key")] = "",
13
+ key_file: Annotated[str, Option(description="Read the armored public key from a file path", name="key-file")] = "",
14
+ ) -> None:
15
+ """Add a GPG key."""
16
+ client = require_client()
17
+
18
+ value = armored_key
19
+ if key_file:
20
+ file_path = Path(key_file)
21
+ if not file_path.exists():
22
+ rich.print(f"[bold red]Error:[/bold red] File not found: {key_file}")
23
+ return 1
24
+ value = file_path.read_text().strip()
25
+
26
+ if not value:
27
+ rich.print("[bold red]Error:[/bold red] No key provided (pass an armored_key argument or --key-file)")
28
+ return 1
29
+
30
+ result = client.post("/user/gpg_keys", data={"armored_public_key": value})
31
+ rich.print(f"[bold green]Added GPG key[/bold green] #{result['id']}")
@@ -0,0 +1,17 @@
1
+ from typing import Annotated
2
+
3
+ import rich
4
+
5
+ from codeberg_cli.helpers import require_client
6
+ from xclif import Arg, command
7
+
8
+
9
+ @command("delete")
10
+ def _(
11
+ id: Annotated[int, Arg(description="GPG key ID")],
12
+ ) -> None:
13
+ """Delete a GPG key."""
14
+ client = require_client()
15
+
16
+ client.delete(f"/user/gpg_keys/{id}")
17
+ rich.print(f"[bold green]Deleted GPG key[/bold green] #{id}")
@@ -0,0 +1,29 @@
1
+ import rich
2
+
3
+ from codeberg_cli.helpers import print_table, require_client
4
+ from xclif import command
5
+
6
+
7
+ @command("list", "ls")
8
+ def _() -> None:
9
+ """List your GPG keys."""
10
+ client = require_client()
11
+
12
+ keys = client.get("/user/gpg_keys")
13
+
14
+ if not keys:
15
+ rich.print("[dim]No GPG keys.[/dim]")
16
+ return
17
+
18
+ rows = []
19
+ json_rows = []
20
+ for key in keys:
21
+ rows.append((str(key["id"]), key.get("key_id", ""), str(key.get("can_sign", False))))
22
+ json_rows.append({
23
+ "id": key["id"],
24
+ "key_id": key.get("key_id"),
25
+ "can_sign": key.get("can_sign"),
26
+ "created_at": key.get("created_at"),
27
+ })
28
+
29
+ print_table(["ID", "Key ID", "Can Sign"], rows, json_data=json_rows)
@@ -0,0 +1,6 @@
1
+ from xclif import command
2
+
3
+
4
+ @command("ssh")
5
+ def _() -> None:
6
+ """Manage SSH keys."""
@@ -0,0 +1,31 @@
1
+ from pathlib import Path
2
+ from typing import Annotated
3
+
4
+ import rich
5
+
6
+ from codeberg_cli.helpers import require_client
7
+ from xclif import Arg, Option, command
8
+
9
+
10
+ @command("add")
11
+ def _(
12
+ title: Annotated[str, Arg(description="Title for the SSH key")],
13
+ key: Annotated[str, Option(description="The public key string", name="key")] = "",
14
+ key_file: Annotated[str, Option(description="Read the public key from a file path", name="key-file")] = "",
15
+ ) -> None:
16
+ """Add an SSH key."""
17
+ client = require_client()
18
+
19
+ if key_file:
20
+ file_path = Path(key_file)
21
+ if not file_path.exists():
22
+ rich.print(f"[bold red]Error:[/bold red] File not found: {key_file}")
23
+ return 1
24
+ key = file_path.read_text().strip()
25
+
26
+ if not key:
27
+ rich.print("[bold red]Error:[/bold red] No key provided (pass a key argument or --key-file)")
28
+ return 1
29
+
30
+ result = client.post("/user/keys", data={"title": title, "key": key})
31
+ rich.print(f"[bold green]Added SSH key[/bold green] #{result['id']}")
@@ -0,0 +1,17 @@
1
+ from typing import Annotated
2
+
3
+ import rich
4
+
5
+ from codeberg_cli.helpers import require_client
6
+ from xclif import Arg, command
7
+
8
+
9
+ @command("delete")
10
+ def _(
11
+ id: Annotated[int, Arg(description="SSH key ID")],
12
+ ) -> None:
13
+ """Delete an SSH key."""
14
+ client = require_client()
15
+
16
+ client.delete(f"/user/keys/{id}")
17
+ rich.print(f"[bold green]Deleted SSH key[/bold green] #{id}")