pygitcode 0.1.0__py3-none-any.whl → 0.1.2__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.
gitcode_cli/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  __all__ = ["__version__"]
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.1.2"
@@ -0,0 +1,7 @@
1
+ from __future__ import annotations
2
+
3
+ from .base import AdapterActionResult
4
+ from .issues import IssueAdapter
5
+ from .pulls import PullRequestAdapter
6
+
7
+ __all__ = ["AdapterActionResult", "IssueAdapter", "PullRequestAdapter"]
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+
7
+ @dataclass
8
+ class AdapterActionResult:
9
+ item: dict[str, Any] | None = None
10
+ items: list[dict[str, Any]] | None = None
11
+ message: str | None = None
12
+ warning: str | None = None
13
+ degraded: bool = False
14
+ approximated: bool = False
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ import click
4
+
5
+ CAPABILITY_MESSAGES = {
6
+ "ISSUE_COMMENT_LAST_NOT_FOUND": "No editable issue comment from the current user was found.",
7
+ "ISSUE_COMMENT_OWNERSHIP_UNVERIFIABLE": (
8
+ "Unable to verify the current user's issue comments on GitCode; refusing to edit or delete comments safely."
9
+ ),
10
+ "ISSUE_CREATE_IF_NONE_REQUIRES_EDIT_LAST": "--create-if-none can only be used together with --edit-last.",
11
+ "ISSUE_DELETE": "GitCode API does not support deleting issues.",
12
+ "ISSUE_DEVELOP_BASE": "--base and --name are not supported by 'gc issue develop'",
13
+ "ISSUE_DEVELOP_NAME": "--base and --name are not supported by 'gc issue develop'",
14
+ "ISSUE_STATUS_GH_SEMANTICS": "GitCode-limited approximation of gh issue status",
15
+ "PR_MERGE_AUTHOR_EMAIL": "GitCode merge API does not support --author-email.",
16
+ "PR_MERGE_AUTO": "GitCode merge API does not support --auto.",
17
+ "PR_REVIEW_REQUEST_CHANGES": (
18
+ "GitCode review API does not support request-changes reviews; the pull request comment was posted instead."
19
+ ),
20
+ "PR_STATUS_GH_SEMANTICS": ("GitCode API approximation -- user-specific filtering is not available"),
21
+ }
22
+
23
+
24
+ def capability_message(feature: str) -> str:
25
+ return CAPABILITY_MESSAGES[feature]
26
+
27
+
28
+ def unsupported(feature: str) -> click.ClickException:
29
+ return click.ClickException(capability_message(feature))
@@ -0,0 +1,239 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import click
6
+
7
+ from ..errors import APIError
8
+ from ..services import IssueService, UserService
9
+ from .base import AdapterActionResult
10
+ from .capabilities import capability_message, unsupported
11
+
12
+
13
+ def _normalize_multi_values(values: tuple[str, ...] | None) -> str | None:
14
+ if not values:
15
+ return None
16
+ return ",".join(values)
17
+
18
+
19
+ def _extract_label_names(issue: dict[str, Any] | None) -> list[str]:
20
+ labels = issue.get("labels") if isinstance(issue, dict) else None
21
+ if not labels:
22
+ return []
23
+ if isinstance(labels, str):
24
+ return [part.strip() for part in labels.split(",") if part.strip()]
25
+
26
+ names: list[str] = []
27
+ if isinstance(labels, list):
28
+ for label in labels:
29
+ if isinstance(label, dict):
30
+ name = label.get("name") or label.get("title")
31
+ if name:
32
+ names.append(str(name))
33
+ elif label:
34
+ names.append(str(label))
35
+ return names
36
+
37
+
38
+ def _extract_login(data: dict[str, Any] | None) -> str | None:
39
+ if not isinstance(data, dict):
40
+ return None
41
+ for key in ("login", "username", "name"):
42
+ value = data.get(key)
43
+ if isinstance(value, str) and value.strip():
44
+ return value.strip()
45
+ user = data.get("user")
46
+ if isinstance(user, dict):
47
+ return _extract_login(user)
48
+ author = data.get("author")
49
+ if isinstance(author, dict):
50
+ return _extract_login(author)
51
+ return None
52
+
53
+
54
+ class IssueAdapter:
55
+ def __init__(self, service: IssueService, user_service: UserService | None = None):
56
+ self.service = service
57
+ self.user_service = user_service
58
+
59
+ def list_issues(
60
+ self,
61
+ owner: str,
62
+ repo: str,
63
+ *,
64
+ state: str | None,
65
+ labels: tuple[str, ...] | None,
66
+ author: str | None,
67
+ assignee: str | None,
68
+ milestone: str | None,
69
+ mention: str | None,
70
+ search: str | None,
71
+ limit: int | None,
72
+ ) -> Any:
73
+ items = self.service.list(
74
+ owner,
75
+ repo,
76
+ state=state,
77
+ labels=_normalize_multi_values(labels),
78
+ creator=author,
79
+ assignee=assignee,
80
+ milestone=milestone,
81
+ mention=mention,
82
+ search=search,
83
+ )
84
+ if limit is not None:
85
+ return items[:limit]
86
+ return items
87
+
88
+ def create_issue(
89
+ self,
90
+ owner: str,
91
+ repo: str,
92
+ *,
93
+ title: str,
94
+ body: str | None,
95
+ assignee: str | None,
96
+ labels: tuple[str, ...] | None,
97
+ milestone: str | None,
98
+ ) -> dict[str, Any] | None:
99
+ return self.service.create(
100
+ owner,
101
+ repo,
102
+ title=title,
103
+ body=body,
104
+ assignee=assignee,
105
+ labels=_normalize_multi_values(labels),
106
+ milestone=milestone,
107
+ )
108
+
109
+ def close_issue(
110
+ self,
111
+ owner: str,
112
+ repo: str,
113
+ number: str,
114
+ *,
115
+ comment: str | None,
116
+ reason: str | None,
117
+ ) -> AdapterActionResult:
118
+ current = self.service.get(owner, repo, number)
119
+ if current and current.get("state") == "closed":
120
+ if comment:
121
+ self.service.comment(owner, repo, number, comment)
122
+ return AdapterActionResult(item=current, message="already_closed_commented")
123
+ return AdapterActionResult(item=current, message="already_closed")
124
+ if comment:
125
+ self.service.comment(owner, repo, number, comment)
126
+ payload: dict[str, Any] = {"state": "close"}
127
+ if reason:
128
+ payload["state_reason"] = reason
129
+ item = self.service.update(owner, repo, number, **payload)
130
+ return AdapterActionResult(item=item, message="closed")
131
+
132
+ def comment_issue(self, owner: str, repo: str, number: str, *, body: str) -> dict[str, Any] | None:
133
+ return self.service.comment(owner, repo, number, body)
134
+
135
+ def manage_comment_history(
136
+ self,
137
+ owner: str,
138
+ repo: str,
139
+ number: str,
140
+ *,
141
+ body: str | None,
142
+ delete_last: bool,
143
+ create_if_none: bool,
144
+ ) -> AdapterActionResult:
145
+ comment = self._find_last_owned_comment(owner, repo, number)
146
+ if delete_last:
147
+ if comment is None:
148
+ raise click.ClickException(capability_message("ISSUE_COMMENT_LAST_NOT_FOUND"))
149
+ self.service.delete_comment(owner, repo, comment["id"])
150
+ return AdapterActionResult(item=comment, message="deleted")
151
+ if comment is None:
152
+ if create_if_none:
153
+ item = self.service.comment(owner, repo, number, body or "")
154
+ return AdapterActionResult(item=item, message="created")
155
+ raise click.ClickException(capability_message("ISSUE_COMMENT_LAST_NOT_FOUND"))
156
+ item = self.service.update_comment(owner, repo, comment["id"], body or "")
157
+ return AdapterActionResult(item=item, message="edited")
158
+
159
+ def _find_last_owned_comment(self, owner: str, repo: str, number: str) -> dict[str, Any] | None:
160
+ if self.user_service is None:
161
+ raise click.ClickException(capability_message("ISSUE_COMMENT_OWNERSHIP_UNVERIFIABLE"))
162
+ try:
163
+ current_user = self.user_service.current()
164
+ except APIError as exc:
165
+ raise click.ClickException(capability_message("ISSUE_COMMENT_OWNERSHIP_UNVERIFIABLE")) from exc
166
+ current_login = _extract_login(current_user)
167
+ if not current_login:
168
+ raise click.ClickException(capability_message("ISSUE_COMMENT_OWNERSHIP_UNVERIFIABLE"))
169
+ comments = self.service.list_comments(owner, repo, number) or []
170
+ for comment in reversed(comments):
171
+ if not isinstance(comment, dict):
172
+ continue
173
+ if _extract_login(comment) == current_login:
174
+ return comment
175
+ return None
176
+
177
+ def reopen_issue(self, owner: str, repo: str, number: str) -> AdapterActionResult:
178
+ current = self.service.get(owner, repo, number)
179
+ if current and current.get("state") == "open":
180
+ return AdapterActionResult(item=current, message="already_open")
181
+ item = self.service.update(owner, repo, number, state="reopen")
182
+ return AdapterActionResult(item=item, message="reopened")
183
+
184
+ def delete_issue(self, owner: str, repo: str, number: str) -> AdapterActionResult: # noqa: ARG002
185
+ raise unsupported("ISSUE_DELETE")
186
+
187
+ def edit_issue(
188
+ self,
189
+ owner: str,
190
+ repo: str,
191
+ number: str,
192
+ *,
193
+ title: str | None,
194
+ body: str | None,
195
+ add_assignee: str | None,
196
+ add_labels: tuple[str, ...] | None,
197
+ milestone: str | None,
198
+ remove_milestone: bool,
199
+ ) -> dict[str, Any] | None:
200
+ data = {k: v for k, v in {"title": title, "body": body}.items() if v is not None}
201
+
202
+ if add_assignee is not None:
203
+ data["assignee"] = add_assignee
204
+
205
+ if add_labels:
206
+ current = self.service.get(owner, repo, number)
207
+ existing = _extract_label_names(current)
208
+ merged: list[str] = []
209
+ seen: set[str] = set()
210
+ for label in [*existing, *list(add_labels)]:
211
+ normalized = label.strip()
212
+ if normalized and normalized not in seen:
213
+ seen.add(normalized)
214
+ merged.append(normalized)
215
+ data["labels"] = ",".join(merged)
216
+
217
+ if milestone is not None:
218
+ data["milestone"] = milestone
219
+ if remove_milestone:
220
+ data["milestone"] = ""
221
+ return self.service.update(owner, repo, number, **data)
222
+
223
+ def status(self, owner: str, repo: str) -> AdapterActionResult:
224
+ items = self.service.list(owner, repo, state="open")
225
+ return AdapterActionResult(
226
+ items=items,
227
+ message=capability_message("ISSUE_STATUS_GH_SEMANTICS"),
228
+ approximated=True,
229
+ )
230
+
231
+ def develop(self, owner: str, repo: str, number: str, *, base: str | None, name: str | None) -> AdapterActionResult: # noqa: ARG002
232
+ if base is not None:
233
+ raise unsupported("ISSUE_DEVELOP_BASE")
234
+ if name is not None:
235
+ raise unsupported("ISSUE_DEVELOP_NAME")
236
+ return AdapterActionResult(
237
+ message=f"Opening issue #{number} in the browser instead.",
238
+ warning="Note: 'issue develop' does not create a local branch on GitCode.",
239
+ )
@@ -0,0 +1,170 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from ..services import PullRequestService
6
+ from .base import AdapterActionResult
7
+ from .capabilities import capability_message
8
+
9
+
10
+ def _normalize_multi_values(values: tuple[str, ...] | None) -> str | None:
11
+ if not values:
12
+ return None
13
+ return ",".join(values)
14
+
15
+
16
+ class PullRequestAdapter:
17
+ def __init__(self, service: PullRequestService):
18
+ self.service = service
19
+
20
+ def list_prs(
21
+ self,
22
+ owner: str,
23
+ repo: str,
24
+ *,
25
+ state: str | None,
26
+ author: str | None,
27
+ base: str | None,
28
+ assignee: str | None,
29
+ draft: bool | None,
30
+ head: str | None,
31
+ labels: tuple[str, ...] | None,
32
+ search: str | None,
33
+ limit: int | None,
34
+ ) -> Any:
35
+ items = self.service.list(
36
+ owner,
37
+ repo,
38
+ state=state,
39
+ author=author,
40
+ base=base,
41
+ assignee=assignee,
42
+ draft=draft,
43
+ head=head,
44
+ labels=_normalize_multi_values(labels),
45
+ search=search,
46
+ )
47
+ if limit is not None:
48
+ return items[:limit]
49
+ return items
50
+
51
+ def create_pr(
52
+ self,
53
+ owner: str,
54
+ repo: str,
55
+ *,
56
+ title: str,
57
+ body: str | None,
58
+ base: str,
59
+ head: str,
60
+ draft: bool,
61
+ milestone: str | None,
62
+ labels: tuple[str, ...] | None,
63
+ reviewers: tuple[str, ...] | None,
64
+ assignees: tuple[str, ...] | None,
65
+ dry_run: bool,
66
+ ) -> AdapterActionResult:
67
+ payload = {
68
+ "title": title,
69
+ "body": body,
70
+ "base": base,
71
+ "head": head,
72
+ "draft": draft,
73
+ "labels": _normalize_multi_values(labels),
74
+ "assignees": _normalize_multi_values(assignees),
75
+ "reviewers": _normalize_multi_values(reviewers),
76
+ "milestone": milestone,
77
+ }
78
+ if dry_run:
79
+ return AdapterActionResult(item={k: v for k, v in payload.items() if v is not None})
80
+ return AdapterActionResult(item=self.service.create(owner, repo, **payload))
81
+
82
+ def merge_pr(
83
+ self,
84
+ owner: str,
85
+ repo: str,
86
+ number: int,
87
+ *,
88
+ merge_method: str,
89
+ body: str | None,
90
+ subject: str | None,
91
+ admin: bool,
92
+ ) -> dict[str, Any] | None:
93
+ payload = {
94
+ "merge_method": merge_method,
95
+ "description": body,
96
+ "title": subject,
97
+ "force_merge": True if admin else None,
98
+ }
99
+ return self.service.merge(owner, repo, number, **payload)
100
+
101
+ def review_pr(
102
+ self,
103
+ owner: str,
104
+ repo: str,
105
+ number: int,
106
+ *,
107
+ approve: bool, # noqa: ARG002
108
+ body: str | None,
109
+ comment: bool,
110
+ request_changes: bool,
111
+ force: bool,
112
+ ) -> AdapterActionResult:
113
+ if comment:
114
+ item = self.service.comment(owner, repo, number, body=body or "")
115
+ return AdapterActionResult(item=item)
116
+ if request_changes:
117
+ item = self.service.comment(owner, repo, number, body=body or "")
118
+ return AdapterActionResult(
119
+ item=item,
120
+ message=capability_message("PR_REVIEW_REQUEST_CHANGES"),
121
+ degraded=True,
122
+ )
123
+ item = self.service.review(owner, repo, number, force=force)
124
+ return AdapterActionResult(item=item)
125
+
126
+ def edit_pr(
127
+ self,
128
+ owner: str,
129
+ repo: str,
130
+ number: int,
131
+ *,
132
+ title: str | None,
133
+ body: str | None,
134
+ base: str | None,
135
+ add_assignee: str | None,
136
+ add_label: str | None,
137
+ add_reviewer: str | None,
138
+ remove_assignee: str | None,
139
+ remove_label: str | None,
140
+ remove_reviewer: str | None,
141
+ milestone: str | None,
142
+ remove_milestone: bool,
143
+ ) -> dict[str, Any] | None:
144
+ data = {
145
+ k: v
146
+ for k, v in {
147
+ "title": title,
148
+ "body": body,
149
+ "base": base,
150
+ "assignee": add_assignee,
151
+ "labels": add_label,
152
+ "reviewer": add_reviewer,
153
+ "unassignee": remove_assignee,
154
+ "unset_labels": remove_label,
155
+ "unset_reviewer": remove_reviewer,
156
+ "milestone": milestone,
157
+ }.items()
158
+ if v is not None
159
+ }
160
+ if remove_milestone:
161
+ data["milestone"] = ""
162
+ return self.service.update(owner, repo, number, **data)
163
+
164
+ def status(self, owner: str, repo: str) -> AdapterActionResult:
165
+ items = self.service.list(owner, repo, state="open")
166
+ return AdapterActionResult(
167
+ items=items,
168
+ message=capability_message("PR_STATUS_GH_SEMANTICS"),
169
+ approximated=True,
170
+ )
gitcode_cli/cli.py CHANGED
@@ -1,6 +1,11 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import contextlib
4
+ import importlib.metadata
5
+ import sys
6
+
3
7
  import click
8
+ from click.shell_completion import get_completion_class
4
9
 
5
10
  from . import __version__
6
11
  from .commands.auth import auth_group
@@ -9,14 +14,46 @@ from .commands.pr import pr_group
9
14
  from .config import get_token
10
15
  from .context import AppContext
11
16
  from .errors import GCError
17
+ from .helptext import GCSectionGroup, set_gc_help
18
+ from .utils import safe_echo
19
+
20
+
21
+ def _configure_stdout_encoding() -> None:
22
+ if sys.platform != "win32":
23
+ return
24
+ for stream in (sys.stdout, sys.stderr):
25
+ if hasattr(stream, "reconfigure") and stream.isatty():
26
+ with contextlib.suppress(Exception):
27
+ stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
28
+
29
+
30
+ def _get_version() -> str:
31
+ try:
32
+ return importlib.metadata.version("pygitcode")
33
+ except importlib.metadata.PackageNotFoundError:
34
+ return __version__
12
35
 
13
36
 
14
- @click.group(context_settings={"help_option_names": ["-h", "--help"]})
15
- @click.version_option(version=__version__, prog_name="gc")
37
+ class _GCMainGroup(GCSectionGroup):
38
+ def invoke(self, ctx: click.Context):
39
+ try:
40
+ return super().invoke(ctx)
41
+ except GCError as exc:
42
+ safe_echo(f"error: {exc}", err=True)
43
+ ctx.exit(1)
44
+
45
+
46
+ @click.group(
47
+ cls=_GCMainGroup,
48
+ context_settings={"help_option_names": ["-h", "--help"]},
49
+ help="Work seamlessly with GitCode from the command line.",
50
+ )
51
+ @click.version_option(version=_get_version(), prog_name="gitcode")
16
52
  @click.option("--repo", "repo_name", "-R", help="Select another repository using the [HOST/]OWNER/REPO format.")
17
53
  @click.option("--token", hidden=True, help="Override authentication token.")
18
54
  @click.pass_context
19
55
  def main(ctx: click.Context, repo_name: str | None, token: str | None) -> None:
56
+ _configure_stdout_encoding()
20
57
  ctx.ensure_object(dict)
21
58
  try:
22
59
  resolved_token = token or get_token()
@@ -25,14 +62,42 @@ def main(ctx: click.Context, repo_name: str | None, token: str | None) -> None:
25
62
  ctx.obj["app"] = AppContext(token=resolved_token or "", repo=repo_name)
26
63
 
27
64
 
28
- @main.result_callback()
29
- def process_result(*_args: object, **_kwargs: object) -> None:
30
- return None
65
+ set_gc_help(
66
+ main,
67
+ gc_usage="gc <command> <subcommand> [flags]",
68
+ gc_command_sections=[
69
+ ("CORE COMMANDS", ["auth", "issue", "pr"]),
70
+ ("ADDITIONAL COMMANDS", ["completion", "version"]),
71
+ ],
72
+ gc_examples=[
73
+ "gc issue create",
74
+ "gc pr list -R owner/repo",
75
+ "gc auth login",
76
+ ],
77
+ gc_learn_more=[
78
+ "Use `gc <command> <subcommand> --help` for more information about a command.",
79
+ ],
80
+ )
81
+
31
82
 
83
+ auth_group.short_help = "Authenticate gc with GitCode"
84
+ issue_group.short_help = "Manage issues"
85
+ pr_group.short_help = "Manage pull requests"
32
86
 
33
- @main.command("version")
87
+
88
+ @main.command("version", short_help="Show gc version", help="Show gc version.")
34
89
  def version_command() -> None:
35
- click.echo(f"gc version {__version__}")
90
+ safe_echo(f"gitcode version {_get_version()}")
91
+
92
+
93
+ @main.command("completion", short_help="Generate shell completion scripts", help="Generate shell completion scripts.")
94
+ @click.argument("shell", type=click.Choice(["bash", "zsh", "fish"]))
95
+ def completion_command(shell: str) -> None:
96
+ comp_class = get_completion_class(shell)
97
+ if comp_class is None:
98
+ raise click.ClickException(f"Shell completion not supported for: {shell}")
99
+ comp = comp_class(main, {}, "gc", "_GC_COMPLETE")
100
+ safe_echo(comp.source())
36
101
 
37
102
 
38
103
  main.add_command(auth_group)
gitcode_cli/cli_compat.py CHANGED
@@ -10,6 +10,8 @@ from .utils import get_current_git_branch, get_default_git_branch, read_body_fil
10
10
 
11
11
  def get_body_from_options(body: str | None, body_file: str | None, editor: bool) -> str | None:
12
12
  """Resolve body text from inline text, file/stdin, or editor."""
13
+ if body is not None and body_file:
14
+ raise click.UsageError("--body and --body-file are mutually exclusive")
13
15
  if body is not None:
14
16
  return body
15
17
  if body_file:
gitcode_cli/client.py CHANGED
@@ -5,7 +5,7 @@ from urllib.parse import urljoin
5
5
 
6
6
  import httpx
7
7
 
8
- from .errors import APIError
8
+ from .errors import APIError, NetworkError
9
9
 
10
10
  BASE_URL = "https://api.gitcode.com/api/v5/"
11
11
 
@@ -29,13 +29,27 @@ class GitCodeClient:
29
29
  merged_params: dict[str, Any] = {"access_token": self.token}
30
30
  if params:
31
31
  merged_params.update({k: v for k, v in params.items() if v is not None})
32
- response = self._client.request(
33
- method,
34
- urljoin(self.base_url, path.lstrip("/")),
35
- params=merged_params,
36
- json=json,
37
- headers={"Accept": accept},
38
- )
32
+ try:
33
+ response = self._client.request(
34
+ method,
35
+ urljoin(self.base_url, path.lstrip("/")),
36
+ params=merged_params,
37
+ json=json,
38
+ headers={"Accept": accept},
39
+ )
40
+ except httpx.TimeoutException as exc:
41
+ raise NetworkError(f"Request timed out: {exc}") from exc
42
+ except httpx.ConnectError as exc:
43
+ raise NetworkError(f"Connection failed: {exc}") from exc
44
+ except httpx.HTTPError as exc:
45
+ raise NetworkError(f"Network error: {exc}") from exc
46
+ if response.status_code == 401:
47
+ try:
48
+ data = response.json()
49
+ original = data.get("message") or str(data)
50
+ except Exception:
51
+ original = response.text
52
+ raise APIError(f"Authentication failed: {original}. Run 'gc auth login' to authenticate.", 401)
39
53
  if response.status_code >= 400:
40
54
  try:
41
55
  data = response.json()
@@ -2,15 +2,17 @@ from __future__ import annotations
2
2
 
3
3
  import click
4
4
 
5
- from ..config import save_config
5
+ from ..config import get_token, load_config, save_config
6
+ from ..helptext import GCSectionGroup
7
+ from ..utils import safe_echo
6
8
 
7
9
 
8
- @click.group("auth")
10
+ @click.group("auth", cls=GCSectionGroup, help="Authenticate gc with GitCode.")
9
11
  def auth_group() -> None:
10
12
  pass
11
13
 
12
14
 
13
- @auth_group.command("login")
15
+ @auth_group.command("login", short_help="Authenticate with a GitCode token", help="Authenticate with a GitCode token.")
14
16
  @click.option("--with-token", is_flag=True, help="Read token from stdin.")
15
17
  def auth_login(with_token: bool) -> None:
16
18
  if with_token:
@@ -20,4 +22,34 @@ def auth_login(with_token: bool) -> None:
20
22
  else:
21
23
  token = click.prompt("GitCode token", hide_input=True)
22
24
  save_config({"token": token})
23
- click.echo("Authentication saved.")
25
+ safe_echo("Authentication saved.")
26
+
27
+
28
+ @auth_group.command("logout", short_help="Remove saved authentication", help="Remove saved authentication.")
29
+ def auth_logout() -> None:
30
+ config = load_config()
31
+ if "token" not in config:
32
+ raise click.ClickException("Not logged in.")
33
+ del config["token"]
34
+ save_config(config)
35
+ safe_echo("Logged out.")
36
+
37
+
38
+ @auth_group.command("status", short_help="Show authentication status", help="Show authentication status.")
39
+ def auth_status() -> None:
40
+ try:
41
+ token = get_token()
42
+ except Exception:
43
+ safe_echo("Not logged in. Run `gc auth login` to authenticate.")
44
+ return
45
+ masked = token[:4] + "****" if len(token) > 4 else "****"
46
+ safe_echo(f"Logged in to GitCode (token: {masked})")
47
+
48
+
49
+ @auth_group.command("token", short_help="Print the active token", help="Print the active token.")
50
+ def auth_token() -> None:
51
+ try:
52
+ token = get_token()
53
+ except Exception as exc:
54
+ raise click.ClickException(str(exc)) from exc
55
+ safe_echo(token)