git-copilot-commit 0.4.1__tar.gz → 0.4.3__tar.gz

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 (18) hide show
  1. {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.3}/PKG-INFO +13 -51
  2. {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.3}/README.md +12 -50
  3. {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.3}/src/git_copilot_commit/github_copilot.py +401 -37
  4. {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.3}/.github/workflows/ci.yml +0 -0
  5. {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.3}/.gitignore +0 -0
  6. {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.3}/.justfile +0 -0
  7. {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.3}/.python-version +0 -0
  8. {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.3}/LICENSE +0 -0
  9. {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.3}/pyproject.toml +0 -0
  10. {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.3}/src/git_copilot_commit/__init__.py +0 -0
  11. {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.3}/src/git_copilot_commit/cli.py +0 -0
  12. {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.3}/src/git_copilot_commit/git.py +0 -0
  13. {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.3}/src/git_copilot_commit/prompts/commit-message-generator-prompt.md +0 -0
  14. {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.3}/src/git_copilot_commit/py.typed +0 -0
  15. {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.3}/src/git_copilot_commit/settings.py +0 -0
  16. {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.3}/src/git_copilot_commit/version.py +0 -0
  17. {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.3}/uv.lock +0 -0
  18. {git_copilot_commit-0.4.1 → git_copilot_commit-0.4.3}/vhs/demo.vhs +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-copilot-commit
3
- Version: 0.4.1
3
+ Version: 0.4.3
4
4
  Summary: Automatically generate and commit changes using copilot
5
5
  Author-email: Dheepak Krishnamurthy <1813121+kdheepak@users.noreply.github.com>
6
6
  License-File: LICENSE
@@ -104,44 +104,6 @@ Options:
104
104
  --help Show this message and exit.
105
105
  ```
106
106
 
107
- ### Authenticate
108
-
109
- ```bash
110
- $ uvx git-copilot-commit authenticate --help
111
- Usage: git-copilot-commit authenticate [OPTIONS]
112
-
113
- Autheticate with GitHub Copilot.
114
-
115
- Options:
116
- --help Show this message and exit.
117
- ```
118
-
119
- ### List models
120
-
121
- ```bash
122
- $ uvx git-copilot-commit models --help
123
- Usage: git-copilot-commit models [OPTIONS]
124
-
125
- List models available for chat in a table.
126
-
127
- Options:
128
- --help Show this message and exit.
129
- ```
130
-
131
- ### Configure
132
-
133
- ```bash
134
- $ uvx git-copilot-commit config --help
135
- Usage: git-copilot-commit config [OPTIONS]
136
-
137
- Manage application configuration.
138
-
139
- Options:
140
- --set-default-model TEXT Set default model for commit messages
141
- --show Show current configuration
142
- --help Show this message and exit.
143
- ```
144
-
145
107
  ## Examples
146
108
 
147
109
  Commit all changes:
@@ -162,14 +124,6 @@ Use a specific model:
162
124
  uvx git-copilot-commit commit --model claude-3.5-sonnet
163
125
  ```
164
126
 
165
- Set and use a default model:
166
-
167
- ```bash
168
- uvx git-copilot-commit config --set-default-model gpt-4o
169
- uvx git-copilot-commit commit
170
- uvx git-copilot-commit commit --model claude-3.5-sonnet
171
- ```
172
-
173
127
  ## Commit Message Format
174
128
 
175
129
  Follows [Conventional Commits](https://www.conventionalcommits.org/):
@@ -199,15 +153,23 @@ Add a git alias by adding the following to your `~/.gitconfig`:
199
153
  ai-commit = "!f() { uvx git-copilot-commit commit $@; }; f"
200
154
  ```
201
155
 
202
- Now you can run:
156
+ Now you can run to review the message before committing:
203
157
 
204
158
  ```bash
205
159
  git ai-commit
206
- git ai-commit --all --yes --model claude-3.5-sonnet
207
160
  ```
208
161
 
209
- Additionally, show more context in diffs by running the following command:
162
+ Alternatively, you can stage all files and auto accept the commit message and
163
+ specify which model should be used to generate the commit in one CLI invocation.
210
164
 
211
165
  ```bash
212
- git config --global diff.context 3
166
+ git ai-commit --all --yes --model claude-3.5-sonnet
213
167
  ```
168
+
169
+ > [!TIP]
170
+ >
171
+ > Show more context in diffs by running the following command:
172
+ >
173
+ > ```bash
174
+ > git config --global diff.context 3
175
+ > ```
@@ -91,44 +91,6 @@ Options:
91
91
  --help Show this message and exit.
92
92
  ```
93
93
 
94
- ### Authenticate
95
-
96
- ```bash
97
- $ uvx git-copilot-commit authenticate --help
98
- Usage: git-copilot-commit authenticate [OPTIONS]
99
-
100
- Autheticate with GitHub Copilot.
101
-
102
- Options:
103
- --help Show this message and exit.
104
- ```
105
-
106
- ### List models
107
-
108
- ```bash
109
- $ uvx git-copilot-commit models --help
110
- Usage: git-copilot-commit models [OPTIONS]
111
-
112
- List models available for chat in a table.
113
-
114
- Options:
115
- --help Show this message and exit.
116
- ```
117
-
118
- ### Configure
119
-
120
- ```bash
121
- $ uvx git-copilot-commit config --help
122
- Usage: git-copilot-commit config [OPTIONS]
123
-
124
- Manage application configuration.
125
-
126
- Options:
127
- --set-default-model TEXT Set default model for commit messages
128
- --show Show current configuration
129
- --help Show this message and exit.
130
- ```
131
-
132
94
  ## Examples
133
95
 
134
96
  Commit all changes:
@@ -149,14 +111,6 @@ Use a specific model:
149
111
  uvx git-copilot-commit commit --model claude-3.5-sonnet
150
112
  ```
151
113
 
152
- Set and use a default model:
153
-
154
- ```bash
155
- uvx git-copilot-commit config --set-default-model gpt-4o
156
- uvx git-copilot-commit commit
157
- uvx git-copilot-commit commit --model claude-3.5-sonnet
158
- ```
159
-
160
114
  ## Commit Message Format
161
115
 
162
116
  Follows [Conventional Commits](https://www.conventionalcommits.org/):
@@ -186,15 +140,23 @@ Add a git alias by adding the following to your `~/.gitconfig`:
186
140
  ai-commit = "!f() { uvx git-copilot-commit commit $@; }; f"
187
141
  ```
188
142
 
189
- Now you can run:
143
+ Now you can run to review the message before committing:
190
144
 
191
145
  ```bash
192
146
  git ai-commit
193
- git ai-commit --all --yes --model claude-3.5-sonnet
194
147
  ```
195
148
 
196
- Additionally, show more context in diffs by running the following command:
149
+ Alternatively, you can stage all files and auto accept the commit message and
150
+ specify which model should be used to generate the commit in one CLI invocation.
197
151
 
198
152
  ```bash
199
- git config --global diff.context 3
153
+ git ai-commit --all --yes --model claude-3.5-sonnet
200
154
  ```
155
+
156
+ > [!TIP]
157
+ >
158
+ > Show more context in diffs by running the following command:
159
+ >
160
+ > ```bash
161
+ > git config --global diff.context 3
162
+ > ```
@@ -8,6 +8,7 @@ import secrets
8
8
  import time
9
9
  import uuid
10
10
  from dataclasses import dataclass
11
+ from datetime import datetime
11
12
  from pathlib import Path
12
13
  from typing import Any
13
14
 
@@ -17,7 +18,7 @@ from rich.console import Console
17
18
  from rich.panel import Panel
18
19
  from rich.table import Table
19
20
 
20
- APP_NAME = "git-copilot-commit"
21
+ APP_NAME = "github-copilot-commit"
21
22
  DEFAULT_GITHUB_DOMAIN = "github.com"
22
23
  USER_AGENT = "GitHubCopilotChat/0.35.0"
23
24
  EDITOR_VERSION = "vscode/1.107.0"
@@ -28,6 +29,23 @@ CLIENT_ID = base64.b64decode("SXYxLmI1MDdhMDhjODdlY2ZlOTg=").decode()
28
29
  INITIAL_POLL_INTERVAL_MULTIPLIER = 1.2
29
30
  SLOW_DOWN_POLL_INTERVAL_MULTIPLIER = 1.4
30
31
  DEFAULT_MODEL_ID = "gpt-5.3-codex"
32
+ DEFAULT_MODEL_PREFERENCES = (
33
+ "gpt-5.3-codex",
34
+ "gpt-5.4",
35
+ "claude-opus-4.6",
36
+ "claude-opus-4.5",
37
+ "claude-sonnet-4.6",
38
+ "claude-sonnet-4.5",
39
+ "gemini-2.5-pro",
40
+ "gpt-4.1",
41
+ "gpt-4o",
42
+ )
43
+
44
+ app = typer.Typer(
45
+ add_completion=False,
46
+ no_args_is_help=True,
47
+ help="General-purpose GitHub Copilot CLI.",
48
+ )
31
49
 
32
50
  console = Console()
33
51
  console_err = Console(stderr=True)
@@ -133,6 +151,42 @@ class CopilotModel:
133
151
  )
134
152
 
135
153
 
154
+ @dataclass(slots=True)
155
+ class GitHubViewer:
156
+ login: str
157
+ name: str | None = None
158
+ html_url: str | None = None
159
+ account_type: str | None = None
160
+ plan_name: str | None = None
161
+
162
+ @classmethod
163
+ def from_payload(cls, payload: dict[str, Any]) -> "GitHubViewer":
164
+ login = payload.get("login")
165
+ name = payload.get("name")
166
+ html_url = payload.get("html_url")
167
+ account_type = payload.get("type")
168
+ plan = payload.get("plan")
169
+
170
+ if not isinstance(login, str) or not login:
171
+ raise CopilotError("GitHub user endpoint did not return a login.")
172
+
173
+ plan_name: str | None = None
174
+ if isinstance(plan, dict):
175
+ raw_plan_name = plan.get("name")
176
+ if isinstance(raw_plan_name, str) and raw_plan_name:
177
+ plan_name = raw_plan_name
178
+
179
+ return cls(
180
+ login=login,
181
+ name=name if isinstance(name, str) and name else None,
182
+ html_url=html_url if isinstance(html_url, str) and html_url else None,
183
+ account_type=(
184
+ account_type if isinstance(account_type, str) and account_type else None
185
+ ),
186
+ plan_name=plan_name,
187
+ )
188
+
189
+
136
190
  def xdg_data_home() -> Path:
137
191
  value = os.environ.get("XDG_DATA_HOME")
138
192
  if value:
@@ -175,6 +229,12 @@ def get_urls(domain: str) -> dict[str, str]:
175
229
  }
176
230
 
177
231
 
232
+ def get_github_api_base_url(domain: str) -> str:
233
+ if domain == DEFAULT_GITHUB_DOMAIN:
234
+ return "https://api.github.com"
235
+ return f"https://api.{domain}"
236
+
237
+
178
238
  def get_base_url_from_token(token: str) -> str | None:
179
239
  match = re.search(r"proxy-ep=([^;]+)", token)
180
240
  if not match:
@@ -251,10 +311,63 @@ def request_json(
251
311
  raise CopilotError(f"Invalid JSON response from {url}.") from exc
252
312
 
253
313
 
314
+ def iter_sse_events(response: httpx.Response, url: str):
315
+ event_name: str | None = None
316
+ data_lines: list[str] = []
317
+
318
+ def decode_event(raw_data: str, current_event: str | None) -> Any:
319
+ if raw_data == "[DONE]":
320
+ return None
321
+ try:
322
+ payload = json.loads(raw_data)
323
+ except json.JSONDecodeError as exc:
324
+ label = current_event or "message"
325
+ raise CopilotError(
326
+ f"Invalid SSE event payload from {url} ({label})."
327
+ ) from exc
328
+ if isinstance(payload, dict) and current_event and "type" not in payload:
329
+ payload = dict(payload)
330
+ payload["type"] = current_event
331
+ return payload
332
+
333
+ for raw_line in response.iter_lines():
334
+ line = raw_line if isinstance(raw_line, str) else raw_line.decode("utf-8")
335
+ if not line:
336
+ if data_lines:
337
+ current_event = event_name
338
+ raw_data = "\n".join(data_lines)
339
+ event_name = None
340
+ data_lines = []
341
+ payload = decode_event(raw_data, current_event)
342
+ if payload is not None:
343
+ yield payload
344
+ else:
345
+ event_name = None
346
+ continue
347
+
348
+ if line.startswith(":"):
349
+ continue
350
+
351
+ field, _, value = line.partition(":")
352
+ if value.startswith(" "):
353
+ value = value[1:]
354
+
355
+ if field == "event":
356
+ event_name = value
357
+ continue
358
+ if field == "data":
359
+ data_lines.append(value)
360
+
361
+ if data_lines:
362
+ payload = decode_event("\n".join(data_lines), event_name)
363
+ if payload is not None:
364
+ yield payload
365
+
366
+
254
367
  def load_credentials() -> CopilotCredentials | None:
255
368
  path = credentials_path()
256
369
  if not path.exists():
257
- return None
370
+ return
258
371
 
259
372
  try:
260
373
  raw = json.loads(path.read_text(encoding="utf-8"))
@@ -434,10 +547,34 @@ def refresh_copilot_token(
434
547
  )
435
548
 
436
549
 
550
+ def fetch_github_viewer(
551
+ client: httpx.Client,
552
+ github_access_token: str,
553
+ domain: str,
554
+ ) -> GitHubViewer:
555
+ payload = request_json(
556
+ client,
557
+ "GET",
558
+ f"{get_github_api_base_url(domain)}/user",
559
+ headers={
560
+ "Accept": "application/vnd.github+json",
561
+ "Authorization": f"Bearer {github_access_token}",
562
+ "User-Agent": USER_AGENT,
563
+ },
564
+ )
565
+
566
+ if not isinstance(payload, dict):
567
+ raise CopilotError("GitHub user endpoint returned an invalid response.")
568
+
569
+ return GitHubViewer.from_payload(payload)
570
+
571
+
437
572
  def ensure_fresh_credentials(client: httpx.Client) -> CopilotCredentials:
438
573
  credentials = load_credentials()
439
574
  if credentials is None:
440
- raise CopilotError("No cached Copilot credentials found.")
575
+ raise CopilotError(
576
+ f"No cached Copilot credentials found. Run `{Path(__file__).name} auth login` first."
577
+ )
441
578
 
442
579
  if not credentials.is_expired():
443
580
  return credentials
@@ -455,10 +592,11 @@ def copilot_request_headers(
455
592
  access_token: str,
456
593
  *,
457
594
  intent: str = "conversation-panel",
595
+ accept: str = "application/json",
458
596
  ) -> dict[str, str]:
459
597
  return {
460
598
  "Authorization": f"Bearer {access_token}",
461
- "Accept": "application/json",
599
+ "Accept": accept,
462
600
  "Content-Type": "application/json",
463
601
  "User-Agent": USER_AGENT,
464
602
  "Editor-Version": EDITOR_VERSION,
@@ -518,6 +656,10 @@ def pick_model(
518
656
  )
519
657
  return by_id[requested_model]
520
658
 
659
+ for preferred_model in DEFAULT_MODEL_PREFERENCES:
660
+ if preferred_model in by_id:
661
+ return by_id[preferred_model]
662
+
521
663
  return models[0]
522
664
 
523
665
 
@@ -652,7 +794,7 @@ def chat_completion(
652
794
  }
653
795
  ],
654
796
  "temperature": 0,
655
- "max_tokens": 32,
797
+ "max_tokens": 1024,
656
798
  "stream": False,
657
799
  },
658
800
  )
@@ -672,6 +814,7 @@ def extract_response_text(payload: Any) -> str:
672
814
  raise CopilotError("Responses API returned no output.")
673
815
 
674
816
  parts: list[str] = []
817
+ refusals: list[str] = []
675
818
  for item in output:
676
819
  if not isinstance(item, dict):
677
820
  continue
@@ -684,12 +827,20 @@ def extract_response_text(payload: Any) -> str:
684
827
  text = block.get("text")
685
828
  if isinstance(text, str) and text.strip():
686
829
  parts.append(text.strip())
830
+ continue
831
+ refusal = block.get("refusal")
832
+ if isinstance(refusal, str) and refusal.strip():
833
+ refusals.append(refusal.strip())
687
834
 
688
835
  joined = "\n".join(parts).strip()
689
836
  if joined:
690
837
  return joined
691
838
 
692
- raise CopilotError(f"Responses API output did not contain text: {payload}")
839
+ joined_refusals = "\n".join(refusals).strip()
840
+ if joined_refusals:
841
+ return joined_refusals
842
+
843
+ raise CopilotError("Responses API output did not contain text.")
693
844
 
694
845
 
695
846
  def responses_completion(
@@ -699,32 +850,137 @@ def responses_completion(
699
850
  model: CopilotModel,
700
851
  prompt: str,
701
852
  ) -> str:
702
- payload = request_json(
703
- client,
704
- "POST",
705
- f"{credentials.base_url()}/responses",
706
- headers=copilot_request_headers(
707
- credentials.copilot_token, intent="conversation-edits"
708
- ),
709
- json_body={
710
- "model": model.id,
711
- "input": [
712
- {
713
- "role": "user",
714
- "content": [
715
- {
716
- "type": "input_text",
717
- "text": prompt,
718
- }
719
- ],
720
- }
721
- ],
722
- "stream": False,
723
- "store": False,
724
- "max_output_tokens": 1024,
725
- },
726
- )
727
- return extract_response_text(payload)
853
+ url = f"{credentials.base_url()}/responses"
854
+ request_body = {
855
+ "model": model.id,
856
+ "input": [
857
+ {
858
+ "role": "user",
859
+ "content": [
860
+ {
861
+ "type": "input_text",
862
+ "text": prompt,
863
+ }
864
+ ],
865
+ }
866
+ ],
867
+ "stream": True,
868
+ "store": False,
869
+ }
870
+
871
+ text_parts: list[str] = []
872
+ final_response: dict[str, Any] | None = None
873
+
874
+ try:
875
+ with client.stream(
876
+ "POST",
877
+ url,
878
+ headers=copilot_request_headers(
879
+ credentials.copilot_token,
880
+ intent="conversation-edits",
881
+ accept="text/event-stream",
882
+ ),
883
+ json=request_body,
884
+ ) as response:
885
+ if response.is_error:
886
+ detail = response.read().decode("utf-8", errors="replace").strip()
887
+ if len(detail) > 400:
888
+ detail = f"{detail[:397]}..."
889
+ suffix = f": {detail}" if detail else ""
890
+ raise CopilotError(
891
+ f"{response.status_code} {response.reason_phrase}{suffix}"
892
+ )
893
+
894
+ content_type = response.headers.get("content-type", "")
895
+ if "text/event-stream" not in content_type:
896
+ body = response.read()
897
+ try:
898
+ payload = json.loads(body)
899
+ except ValueError as exc:
900
+ detail = body.decode("utf-8", errors="replace").strip()
901
+ if len(detail) > 400:
902
+ detail = f"{detail[:397]}..."
903
+ raise CopilotError(
904
+ f"Expected an SSE stream from {url}, got {content_type or 'unknown content type'}: {detail}"
905
+ ) from exc
906
+ return extract_response_text(payload)
907
+
908
+ for event in iter_sse_events(response, url):
909
+ if not isinstance(event, dict):
910
+ continue
911
+
912
+ event_type = event.get("type")
913
+ if event_type == "response.output_text.delta":
914
+ delta = event.get("delta")
915
+ if isinstance(delta, str) and delta:
916
+ text_parts.append(delta)
917
+ continue
918
+
919
+ if event_type == "response.output_text.done" and not text_parts:
920
+ text = event.get("text")
921
+ if isinstance(text, str) and text.strip():
922
+ text_parts.append(text)
923
+ continue
924
+
925
+ if event_type == "error":
926
+ error = event.get("error")
927
+ if isinstance(error, dict):
928
+ message = error.get("message")
929
+ code = error.get("code")
930
+ if isinstance(message, str) and message.strip():
931
+ prefix = (
932
+ f"{code}: " if isinstance(code, str) and code else ""
933
+ )
934
+ raise CopilotError(
935
+ f"Responses stream error: {prefix}{message.strip()}"
936
+ )
937
+ raise CopilotError("Responses stream returned an error event.")
938
+
939
+ if event_type in {
940
+ "response.completed",
941
+ "response.failed",
942
+ "response.incomplete",
943
+ }:
944
+ response_payload = event.get("response")
945
+ if isinstance(response_payload, dict):
946
+ final_response = response_payload
947
+ except httpx.HTTPError as exc:
948
+ raise CopilotError(f"Request failed for {url}: {exc}") from exc
949
+
950
+ text = "".join(text_parts).strip()
951
+ if final_response is None:
952
+ if text:
953
+ return text
954
+ raise CopilotError("Responses stream ended without a terminal response event.")
955
+
956
+ status = final_response.get("status")
957
+ if status == "failed":
958
+ error = final_response.get("error")
959
+ if isinstance(error, dict):
960
+ message = error.get("message")
961
+ code = error.get("code")
962
+ if isinstance(message, str) and message.strip():
963
+ prefix = f"{code}: " if isinstance(code, str) and code else ""
964
+ raise CopilotError(
965
+ f"Responses API request failed: {prefix}{message.strip()}"
966
+ )
967
+ raise CopilotError("Responses API request failed.")
968
+
969
+ if status == "incomplete":
970
+ details = final_response.get("incomplete_details")
971
+ reason = "unknown"
972
+ if isinstance(details, dict):
973
+ raw_reason = details.get("reason")
974
+ if isinstance(raw_reason, str) and raw_reason.strip():
975
+ reason = raw_reason.strip()
976
+ if text:
977
+ return f"{text}\n\n[Response incomplete: {reason}]"
978
+ raise CopilotError(f"Responses API response was incomplete: {reason}.")
979
+
980
+ if text:
981
+ return text
982
+
983
+ return extract_response_text(final_response)
728
984
 
729
985
 
730
986
  def complete_text_prompt(
@@ -763,12 +1019,100 @@ def print_model_table(models: list[CopilotModel]) -> None:
763
1019
  console.print(table)
764
1020
 
765
1021
 
766
- def fail(message: str) -> None:
767
- console_err.print(f"[red]Error:[/red] {message}")
768
- raise typer.Exit(1)
1022
+ def format_relative_duration(delta_seconds: int) -> str:
1023
+ remaining = abs(delta_seconds)
1024
+ units = (
1025
+ ("d", 86_400),
1026
+ ("h", 3_600),
1027
+ ("m", 60),
1028
+ ("s", 1),
1029
+ )
1030
+ parts: list[str] = []
1031
+ for suffix, width in units:
1032
+ if remaining < width and suffix != "s":
1033
+ continue
1034
+ value, remaining = divmod(remaining, width)
1035
+ if value == 0 and suffix != "s":
1036
+ continue
1037
+ parts.append(f"{value}{suffix}")
1038
+ if len(parts) == 2:
1039
+ break
1040
+
1041
+ if not parts:
1042
+ parts.append("0s")
1043
+
1044
+ text = " ".join(parts)
1045
+ if delta_seconds < 0:
1046
+ return f"{text} ago"
1047
+ return f"in {text}"
1048
+
1049
+
1050
+ def format_unix_timestamp(timestamp: int) -> str:
1051
+ try:
1052
+ formatted = datetime.fromtimestamp(timestamp).astimezone()
1053
+ except (OSError, OverflowError, ValueError):
1054
+ return str(timestamp)
1055
+
1056
+ delta_seconds = int(timestamp - time.time())
1057
+ return (
1058
+ f"{formatted.strftime('%Y-%m-%d %H:%M:%S %Z')} "
1059
+ f"({format_relative_duration(delta_seconds)})"
1060
+ )
769
1061
 
770
1062
 
771
- def login(enterprise_domain: str | None = None, force: bool = False) -> None:
1063
+ def print_login_summary(
1064
+ domain: str,
1065
+ credentials: CopilotCredentials,
1066
+ github_viewer: GitHubViewer | None = None,
1067
+ models: list[CopilotModel] | None = None,
1068
+ ) -> None:
1069
+ table = Table.grid(padding=(0, 1))
1070
+ table.add_column(style="cyan", no_wrap=True)
1071
+ table.add_column(style="white")
1072
+
1073
+ table.add_row("GitHub host", domain)
1074
+
1075
+ if github_viewer is not None:
1076
+ identity = github_viewer.login
1077
+ if github_viewer.name and github_viewer.name != github_viewer.login:
1078
+ identity = f"{identity} ({github_viewer.name})"
1079
+ table.add_row("GitHub user", identity)
1080
+ if github_viewer.account_type:
1081
+ table.add_row("Account type", github_viewer.account_type)
1082
+ if github_viewer.plan_name:
1083
+ table.add_row("GitHub plan", github_viewer.plan_name)
1084
+ if github_viewer.html_url:
1085
+ table.add_row("GitHub profile", github_viewer.html_url)
1086
+
1087
+ table.add_row("Copilot base URL", credentials.base_url())
1088
+ table.add_row(
1089
+ "Copilot token expires",
1090
+ format_unix_timestamp(credentials.copilot_expires_at),
1091
+ )
1092
+
1093
+ if models is not None:
1094
+ default_model = pick_model(models)
1095
+ table.add_row("Available models", str(len(models)))
1096
+ table.add_row(
1097
+ "Default model",
1098
+ f"{default_model.id} ({infer_api_surface(default_model)})",
1099
+ )
1100
+
1101
+ console.print(Panel.fit(table, title="Login Summary"))
1102
+
1103
+
1104
+ def login(
1105
+ enterprise_domain: str | None = typer.Option(
1106
+ None,
1107
+ "--enterprise-domain",
1108
+ help="GitHub Enterprise hostname. Omit for github.com.",
1109
+ ),
1110
+ force: bool = typer.Option(
1111
+ False,
1112
+ "--force",
1113
+ help="Replace any cached Copilot credentials without prompting.",
1114
+ ),
1115
+ ) -> None:
772
1116
  """Authenticate with GitHub and cache Copilot credentials locally."""
773
1117
  normalized_domain = normalize_domain(enterprise_domain)
774
1118
  if enterprise_domain and not normalized_domain:
@@ -804,9 +1148,29 @@ def login(enterprise_domain: str | None = None, force: bool = False) -> None:
804
1148
  client, github_access_token, normalized_domain
805
1149
  )
806
1150
  path = save_credentials(credentials)
1151
+ github_viewer: GitHubViewer | None = None
1152
+ available_models: list[CopilotModel] | None = None
1153
+ warnings: list[str] = []
1154
+
1155
+ try:
1156
+ github_viewer = fetch_github_viewer(client, github_access_token, domain)
1157
+ except CopilotError as exc:
1158
+ warnings.append(f"Could not fetch GitHub account details: {exc}")
1159
+
1160
+ try:
1161
+ available_models = list_models(client, credentials)
1162
+ except CopilotError as exc:
1163
+ warnings.append(f"Could not fetch Copilot model summary: {exc}")
807
1164
 
808
1165
  console.print(f"[green]Saved Copilot credentials to[/green] {path}")
809
- console.print(f"[green]Resolved Copilot base URL:[/green] {credentials.base_url()}")
1166
+ print_login_summary(
1167
+ domain,
1168
+ credentials,
1169
+ github_viewer=github_viewer,
1170
+ models=available_models,
1171
+ )
1172
+ for warning in warnings:
1173
+ console.print(f"[yellow]Warning:[/yellow] {warning}")
810
1174
 
811
1175
 
812
1176
  def ask(prompt: str, model: str | None = None) -> str: