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.
- {agentctx-0.2.0 → agentctx-0.4.0}/PKG-INFO +5 -4
- {agentctx-0.2.0 → agentctx-0.4.0}/README.md +4 -3
- {agentctx-0.2.0 → agentctx-0.4.0}/agentctx/__init__.py +1 -1
- {agentctx-0.2.0 → agentctx-0.4.0}/agentctx/auth.py +50 -1
- {agentctx-0.2.0 → agentctx-0.4.0}/agentctx/cli.py +7 -6
- {agentctx-0.2.0 → agentctx-0.4.0}/agentctx/profiles.py +39 -1
- {agentctx-0.2.0 → agentctx-0.4.0}/pyproject.toml +1 -1
- {agentctx-0.2.0 → agentctx-0.4.0}/LICENSE +0 -0
- {agentctx-0.2.0 → agentctx-0.4.0}/agentctx/errors.py +0 -0
- {agentctx-0.2.0 → agentctx-0.4.0}/agentctx/paths.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentctx
|
|
3
|
-
Version: 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
|
|
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
|
|
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,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
|
|
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
|
|
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.
|
|
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(
|
|
86
|
+
print('Saved current Codex auth as profile "{0}".'.format(profile_name))
|
|
86
87
|
else:
|
|
87
|
-
print('Current profile renamed to "{0}".'.format(
|
|
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:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|