agentctx 0.2.0__tar.gz → 0.4.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentctx
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: kubectx-like CLI for switching Codex auth profiles
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -30,7 +30,7 @@ Description-Content-Type: text/markdown
30
30
 
31
31
  `agentctx` is a kubectx-like CLI for saving and switching local Codex auth profiles.
32
32
 
33
- It manages copies of `~/.codex/auth.json` under `~/.agentctx` and atomically swaps the active auth file when you switch profiles. Token contents are never printed.
33
+ It manages copies of `~/.codex/auth.json` under `~/.agentctx` and atomically swaps the active auth file when you switch profiles. When saving the current auth via `=.`, the profile name is derived from the user's email claim in the JWT. Token contents are never printed.
34
34
 
35
35
  ## Install
36
36
 
@@ -52,7 +52,8 @@ agentctx # list profiles or open fzf selector when interactive
52
52
  agentctx work # switch to profile
53
53
  agentctx - # switch to previous profile
54
54
  agentctx -c # show current profile
55
- agentctx work=. # save current Codex auth as profile, or rename current profile
55
+ agentctx =. # save/rename current Codex auth as JWT email profile
56
+ agentctx user@example.com=. # same, but validate name matches JWT email
56
57
  agentctx prod=work # rename profile
57
58
  agentctx -d old-profile # delete profile
58
59
  agentctx -u # unset current marker
@@ -74,7 +75,7 @@ CRUD-style subcommands are intentionally not part of the public CLI.
74
75
  ```text
75
76
  ~/.agentctx/
76
77
  profiles/
77
- <name>/
78
+ <email-or-name>/
78
79
  auth.json
79
80
  metadata.json
80
81
  backups/
@@ -2,7 +2,7 @@
2
2
 
3
3
  `agentctx` is a kubectx-like CLI for saving and switching local Codex auth profiles.
4
4
 
5
- It manages copies of `~/.codex/auth.json` under `~/.agentctx` and atomically swaps the active auth file when you switch profiles. Token contents are never printed.
5
+ It manages copies of `~/.codex/auth.json` under `~/.agentctx` and atomically swaps the active auth file when you switch profiles. When saving the current auth via `=.`, the profile name is derived from the user's email claim in the JWT. Token contents are never printed.
6
6
 
7
7
  ## Install
8
8
 
@@ -24,7 +24,8 @@ agentctx # list profiles or open fzf selector when interactive
24
24
  agentctx work # switch to profile
25
25
  agentctx - # switch to previous profile
26
26
  agentctx -c # show current profile
27
- agentctx work=. # save current Codex auth as profile, or rename current profile
27
+ agentctx =. # save/rename current Codex auth as JWT email profile
28
+ agentctx user@example.com=. # same, but validate name matches JWT email
28
29
  agentctx prod=work # rename profile
29
30
  agentctx -d old-profile # delete profile
30
31
  agentctx -u # unset current marker
@@ -46,7 +47,7 @@ CRUD-style subcommands are intentionally not part of the public CLI.
46
47
  ```text
47
48
  ~/.agentctx/
48
49
  profiles/
49
- <name>/
50
+ <email-or-name>/
50
51
  auth.json
51
52
  metadata.json
52
53
  backups/
@@ -1,3 +1,3 @@
1
1
  """agentctx: kubectx-like Codex auth profile switcher."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.4.0"
@@ -1,5 +1,7 @@
1
1
  """Safe operations for Codex auth.json files."""
2
2
 
3
+ import base64
4
+ import binascii
3
5
  import contextlib
4
6
  import datetime as _dt
5
7
  import hashlib
@@ -7,7 +9,7 @@ import json
7
9
  import os
8
10
  import tempfile
9
11
  from pathlib import Path
10
- from typing import Iterator, Optional
12
+ from typing import Any, Iterator, List, Optional
11
13
 
12
14
  from agentctx import paths
13
15
  from agentctx.errors import InvalidAuthJson, LockError, UnsafeAuthFile
@@ -66,10 +68,57 @@ def read_valid_auth_bytes(path: Path) -> bytes:
66
68
  return data
67
69
 
68
70
 
71
+ def read_valid_auth_json(path: Path) -> Any:
72
+ return json.loads(read_valid_auth_bytes(path).decode("utf-8"))
73
+
74
+
69
75
  def validate_auth_json(path: Path) -> None:
70
76
  read_valid_auth_bytes(path)
71
77
 
72
78
 
79
+ def _jwt_payload(token: str) -> Optional[dict]:
80
+ parts = token.split(".")
81
+ if len(parts) != 3 or not parts[1]:
82
+ return None
83
+ payload = parts[1]
84
+ payload += "=" * (-len(payload) % 4)
85
+ try:
86
+ raw = base64.urlsafe_b64decode(payload.encode("ascii"))
87
+ decoded = json.loads(raw.decode("utf-8"))
88
+ except (UnicodeEncodeError, binascii.Error, UnicodeDecodeError, json.JSONDecodeError):
89
+ return None
90
+ return decoded if isinstance(decoded, dict) else None
91
+
92
+
93
+ def _collect_jwt_candidates(value: Any, key: str = "") -> List[str]:
94
+ preferred_keys = {"id_token", "jwt", "token", "access_token"}
95
+ if isinstance(value, str):
96
+ return [value] if key in preferred_keys or value.count(".") == 2 else []
97
+ if isinstance(value, dict):
98
+ preferred = [] # type: List[str]
99
+ rest = [] # type: List[str]
100
+ for child_key, child_value in value.items():
101
+ target = preferred if child_key in preferred_keys else rest
102
+ target.extend(_collect_jwt_candidates(child_value, str(child_key)))
103
+ return preferred + rest
104
+ if isinstance(value, list):
105
+ result = [] # type: List[str]
106
+ for item in value:
107
+ result.extend(_collect_jwt_candidates(item, key))
108
+ return result
109
+ return []
110
+
111
+
112
+ def email_from_auth_jwt(path: Path) -> str:
113
+ data = read_valid_auth_json(path)
114
+ for token in _collect_jwt_candidates(data):
115
+ payload = _jwt_payload(token)
116
+ email = payload.get("email") if payload else None
117
+ if isinstance(email, str) and email.strip():
118
+ return email.strip().lower()
119
+ raise InvalidAuthJson("JWT email not found in {0}".format(path))
120
+
121
+
73
122
  def sha256_file(path: Path) -> str:
74
123
  reject_unsafe_existing_file(path)
75
124
  digest = hashlib.sha256()
@@ -17,7 +17,8 @@ USAGE:
17
17
  agentctx - : switch to the previous profile
18
18
  agentctx -c, --current : show the current profile name
19
19
  agentctx <NEW_NAME>=<NAME> : rename profile <NAME> to <NEW_NAME>
20
- agentctx <NEW_NAME>=. : save or rename current active auth to <NEW_NAME>
20
+ agentctx =. : save or rename current active auth using JWT email
21
+ agentctx <EMAIL>=. : same, but validate <EMAIL> matches JWT email
21
22
  agentctx -d <NAME> [<NAME...>] : delete profile <NAME> ('.' for current profile)
22
23
  (this command won't delete the active auth file
23
24
  that is used by Codex)
@@ -40,7 +41,7 @@ def _print_profile_list() -> int:
40
41
 
41
42
 
42
43
  def _maybe_interactive_select() -> Optional[str]:
43
- if not sys.stdout.isatty():
44
+ if not sys.stdin.isatty() or not sys.stdout.isatty():
44
45
  return None
45
46
  if shutil.which("fzf") is None:
46
47
  return None
@@ -77,14 +78,14 @@ def _list_or_select() -> int:
77
78
 
78
79
  def _handle_rename_or_save(spec: str) -> int:
79
80
  new_name, old_name = spec.split("=", 1)
80
- if not new_name or not old_name:
81
+ if not old_name or (not new_name and old_name != "."):
81
82
  raise AgentctxError("invalid rename syntax: {0}".format(spec))
82
83
  if old_name == ".":
83
- outcome = profiles.save_or_rename_current(new_name)
84
+ outcome, profile_name = profiles.save_or_rename_current_from_jwt_email(new_name or None)
84
85
  if outcome == "saved":
85
- print('Saved current Codex auth as profile "{0}".'.format(new_name))
86
+ print('Saved current Codex auth as profile "{0}".'.format(profile_name))
86
87
  else:
87
- print('Current profile renamed to "{0}".'.format(new_name))
88
+ print('Current profile renamed to "{0}".'.format(profile_name))
88
89
  return 0
89
90
  profiles.rename_profile(old_name, new_name)
90
91
  print('Profile "{0}" renamed to "{1}".'.format(old_name, new_name))
@@ -10,7 +10,8 @@ from typing import Dict, Iterable, List, Optional, Tuple
10
10
  from agentctx import auth, paths
11
11
  from agentctx.errors import InvalidProfileName, NoCurrentProfile, ProfileAlreadyExists, ProfileNotFound, UnsafeAuthFile
12
12
 
13
- PROFILE_RE = re.compile(r"^[A-Za-z0-9._-]+$")
13
+ PROFILE_RE = re.compile(r"^[A-Za-z0-9._%+@-]+$")
14
+ EMAIL_PROFILE_RE = re.compile(r"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z0-9.-]+$")
14
15
  LEGACY_WORDS = {"save", "use", "sync", "backup", "doctor", "rename", "delete", "list", "current"}
15
16
  UNKNOWN_ACTIVE_PROFILE = "Unknown active profile"
16
17
 
@@ -26,6 +27,18 @@ def validate_profile_name(name: str) -> None:
26
27
  raise InvalidProfileName("invalid profile name: {0}".format(name))
27
28
 
28
29
 
30
+ def validate_jwt_email_profile_name(name: str) -> None:
31
+ validate_profile_name(name)
32
+ if not EMAIL_PROFILE_RE.match(name):
33
+ raise InvalidProfileName("invalid JWT email for profile name: {0}".format(name))
34
+
35
+
36
+ def _active_auth_email_profile_name() -> str:
37
+ name = auth.email_from_auth_jwt(paths.codex_auth_path())
38
+ validate_jwt_email_profile_name(name)
39
+ return name
40
+
41
+
29
42
  def _reject_unsafe_profile_dir(name: str) -> None:
30
43
  pdir = paths.profile_dir(name)
31
44
  if pdir.is_symlink():
@@ -272,6 +285,31 @@ def save_or_rename_current(new: str) -> str:
272
285
  return "saved"
273
286
 
274
287
 
288
+ def save_or_rename_current_from_jwt_email(expected_name: Optional[str] = None) -> Tuple[str, str]:
289
+ with auth.lock():
290
+ new = _active_auth_email_profile_name()
291
+ if expected_name:
292
+ validate_jwt_email_profile_name(expected_name)
293
+ if expected_name.lower() != new:
294
+ raise InvalidProfileName(
295
+ "profile name must match JWT email: {0}".format(new)
296
+ )
297
+
298
+ current = get_current_profile()
299
+ if current:
300
+ if current == new:
301
+ created_at = _read_created_at(current)
302
+ auth.copy_auth_atomic(paths.codex_auth_path(), paths.profile_auth_path(current))
303
+ _write_metadata(current, created_at=created_at)
304
+ _set_current(current)
305
+ return "saved", new
306
+ _rename_profile_locked(current, new)
307
+ return "renamed", new
308
+
309
+ _save_current_as_profile_locked(new)
310
+ return "saved", new
311
+
312
+
275
313
  def delete_profiles(requested_names: Iterable[str]) -> List[str]:
276
314
  requested = list(requested_names)
277
315
  if not requested:
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "agentctx"
3
- version = "0.2.0"
3
+ version = "0.4.0"
4
4
  description = "kubectx-like CLI for switching Codex auth profiles"
5
5
  authors = ["Nikolay Baryshnikov <root@k0d.ru>"]
6
6
  packages = [
File without changes
File without changes
File without changes