taktcli 0.6.2__tar.gz → 0.7.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.
Files changed (61) hide show
  1. {taktcli-0.6.2 → taktcli-0.7.0}/PKG-INFO +40 -23
  2. {taktcli-0.6.2 → taktcli-0.7.0}/README.md +39 -22
  3. {taktcli-0.6.2 → taktcli-0.7.0}/pyproject.toml +1 -1
  4. {taktcli-0.6.2 → taktcli-0.7.0}/taktcli/__main__.py +8 -1
  5. taktcli-0.7.0/taktcli/auth.py +188 -0
  6. {taktcli-0.6.2 → taktcli-0.7.0}/taktcli/commands/_exec.py +2 -2
  7. {taktcli-0.6.2 → taktcli-0.7.0}/taktcli/commands/_run.py +3 -3
  8. taktcli-0.7.0/taktcli/commands/auth.py +429 -0
  9. {taktcli-0.6.2 → taktcli-0.7.0}/taktcli/commands/file.py +4 -4
  10. {taktcli-0.6.2 → taktcli-0.7.0}/taktcli/commands/gql.py +2 -2
  11. {taktcli-0.6.2 → taktcli-0.7.0}/taktcli/commands/schema.py +2 -2
  12. taktcli-0.7.0/taktcli/credentials.py +246 -0
  13. taktcli-0.7.0/taktcli/oauth.py +228 -0
  14. {taktcli-0.6.2 → taktcli-0.7.0}/taktcli/transport.py +36 -1
  15. {taktcli-0.6.2 → taktcli-0.7.0}/taktcli/watch.py +60 -19
  16. {taktcli-0.6.2 → taktcli-0.7.0}/taktcli.egg-info/PKG-INFO +40 -23
  17. {taktcli-0.6.2 → taktcli-0.7.0}/taktcli.egg-info/SOURCES.txt +4 -0
  18. taktcli-0.7.0/tests/test_auth.py +261 -0
  19. taktcli-0.7.0/tests/test_auth_commands.py +459 -0
  20. taktcli-0.7.0/tests/test_credentials.py +183 -0
  21. {taktcli-0.6.2 → taktcli-0.7.0}/tests/test_gql.py +0 -1
  22. taktcli-0.7.0/tests/test_refresh_concurrency.py +64 -0
  23. {taktcli-0.6.2 → taktcli-0.7.0}/tests/test_watch.py +96 -1
  24. taktcli-0.6.2/taktcli/auth.py +0 -104
  25. taktcli-0.6.2/taktcli/commands/auth.py +0 -213
  26. taktcli-0.6.2/tests/test_auth.py +0 -75
  27. taktcli-0.6.2/tests/test_auth_commands.py +0 -268
  28. {taktcli-0.6.2 → taktcli-0.7.0}/setup.cfg +0 -0
  29. {taktcli-0.6.2 → taktcli-0.7.0}/taktcli/__init__.py +0 -0
  30. {taktcli-0.6.2 → taktcli-0.7.0}/taktcli/commands/__init__.py +0 -0
  31. {taktcli-0.6.2 → taktcli-0.7.0}/taktcli/commands/hotpath.py +0 -0
  32. {taktcli-0.6.2 → taktcli-0.7.0}/taktcli/commands/label.py +0 -0
  33. {taktcli-0.6.2 → taktcli-0.7.0}/taktcli/commands/notif.py +0 -0
  34. {taktcli-0.6.2 → taktcli-0.7.0}/taktcli/commands/project.py +0 -0
  35. {taktcli-0.6.2 → taktcli-0.7.0}/taktcli/commands/task.py +0 -0
  36. {taktcli-0.6.2 → taktcli-0.7.0}/taktcli/commands/watch.py +0 -0
  37. {taktcli-0.6.2 → taktcli-0.7.0}/taktcli/commands/wiki.py +0 -0
  38. {taktcli-0.6.2 → taktcli-0.7.0}/taktcli/commands/wikis.py +0 -0
  39. {taktcli-0.6.2 → taktcli-0.7.0}/taktcli/commands/workspace.py +0 -0
  40. {taktcli-0.6.2 → taktcli-0.7.0}/taktcli/errors.py +0 -0
  41. {taktcli-0.6.2 → taktcli-0.7.0}/taktcli/introspection.py +0 -0
  42. {taktcli-0.6.2 → taktcli-0.7.0}/taktcli/io.py +0 -0
  43. {taktcli-0.6.2 → taktcli-0.7.0}/taktcli.egg-info/dependency_links.txt +0 -0
  44. {taktcli-0.6.2 → taktcli-0.7.0}/taktcli.egg-info/entry_points.txt +0 -0
  45. {taktcli-0.6.2 → taktcli-0.7.0}/taktcli.egg-info/requires.txt +0 -0
  46. {taktcli-0.6.2 → taktcli-0.7.0}/taktcli.egg-info/top_level.txt +0 -0
  47. {taktcli-0.6.2 → taktcli-0.7.0}/tests/test_cli.py +0 -0
  48. {taktcli-0.6.2 → taktcli-0.7.0}/tests/test_file.py +0 -0
  49. {taktcli-0.6.2 → taktcli-0.7.0}/tests/test_hotpath.py +0 -0
  50. {taktcli-0.6.2 → taktcli-0.7.0}/tests/test_introspection.py +0 -0
  51. {taktcli-0.6.2 → taktcli-0.7.0}/tests/test_io.py +0 -0
  52. {taktcli-0.6.2 → taktcli-0.7.0}/tests/test_label.py +0 -0
  53. {taktcli-0.6.2 → taktcli-0.7.0}/tests/test_notif.py +0 -0
  54. {taktcli-0.6.2 → taktcli-0.7.0}/tests/test_project.py +0 -0
  55. {taktcli-0.6.2 → taktcli-0.7.0}/tests/test_schema.py +0 -0
  56. {taktcli-0.6.2 → taktcli-0.7.0}/tests/test_task.py +0 -0
  57. {taktcli-0.6.2 → taktcli-0.7.0}/tests/test_transport.py +0 -0
  58. {taktcli-0.6.2 → taktcli-0.7.0}/tests/test_version.py +0 -0
  59. {taktcli-0.6.2 → taktcli-0.7.0}/tests/test_wiki.py +0 -0
  60. {taktcli-0.6.2 → taktcli-0.7.0}/tests/test_wikis.py +0 -0
  61. {taktcli-0.6.2 → taktcli-0.7.0}/tests/test_workspace.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: taktcli
3
- Version: 0.6.2
3
+ Version: 0.7.0
4
4
  Summary: Authorized GraphQL CLI for Takt — full GraphQL surface from the shell, stdlib-only core
5
5
  Author: Takt
6
6
  License: MIT
@@ -43,8 +43,8 @@ the supported replacement for the retired `tools/wait_for_task.py`.
43
43
  ## Quickstart
44
44
 
45
45
  ```bash
46
- taktcli login # device-auth, stores ~/.takt/credentials.json
47
- taktcli whoami # nickname + claims + active credential source
46
+ taktcli login # browser approval, stores a self-refreshing profile
47
+ taktcli whoami # subject, owner, workspace, expiry, credential source
48
48
  taktcli board --project takt # show the board
49
49
  taktcli claim takt-42 # READY → RUNNING, prints leaseId
50
50
  taktcli run takt-42 # heartbeat the lease
@@ -58,33 +58,50 @@ lists a group's verbs.
58
58
 
59
59
  Token resolution, simplest-wins:
60
60
 
61
- 1. **`TAKT_TOKEN` env** → used verbatim. This is what harness containers are
62
- stuffed with; env always wins over any stored credential.
63
- 2. Else **`~/.takt/credentials.json`** — plain JSON `{ "token": "<jwt>", "url": "..." }`,
64
- mode `0600`, written by `taktcli login`.
61
+ 1. **`TAKT_TOKEN` env** → used verbatim and never refreshed. This is what harness
62
+ containers are stuffed with; env always wins over any stored credential.
63
+ 2. Else the selected **profile** in `~/.takt/credentials.json`, written by
64
+ `taktcli login`. The profile is `--profile NAME` → `TAKT_PROFILE` → the
65
+ store's current profile → `default`.
65
66
  3. Else error: `No Takt credentials found. Run: taktcli login` (exit `3`).
66
67
 
67
- URL resolution mirrors it: `TAKT_URL` env → `url` in the credentials file →
68
- default `https://api.takt.sh/graphql`. The `watch` WS endpoint is derived by
69
- swapping the scheme (`https → wss`).
68
+ URL resolution mirrors it: `TAKT_URL` env → the profile's URL → default
69
+ `https://api.takt.sh/graphql`. The `watch` WS endpoint is derived by swapping the
70
+ scheme (`https → wss`).
70
71
 
71
- `taktcli login` runs the OAuth 2.0 **device-authorization grant**: it prints a
72
- verification URL + user code, polls until you approve in a browser, then persists
73
- the token. `logout` removes the stored credential; `whoami` prints who you are
74
- and which credential source is active.
72
+ `taktcli login` runs the OAuth 2.0 **device grant** (RFC 8628). It prints
73
+ `Open <url> and enter <code>` to stderr, opens that page in your browser with the
74
+ code filled in (unless `--no-browser`, or there is no display), and waits for you
75
+ to approve. `--as <agent>` preselects an agent you own on the approval page, so
76
+ the CLI acts as that agent while you stay its owner.
75
77
 
76
78
  ```bash
77
- taktcli login # default endpoint
78
- taktcli login --url http://localhost:8454/graphql # override the endpoint
79
- taktcli logout
80
- taktcli whoami
79
+ taktcli login # you, profile "default"
80
+ taktcli login --as claude-code --profile claude-code
81
+ taktcli login --no-browser # headless: just print the URL + code
82
+ taktcli login --url http://localhost:8454/graphql # another server
83
+ taktcli whoami # subject, owner, client, workspace, expiry, source
84
+ taktcli profile list # stored profiles (no secrets)
85
+ taktcli profile use claude-code
86
+ taktcli logout # revoke the grant, then delete the profile
81
87
  ```
82
88
 
83
- Auth is the same JWT + claims used by MCP and GraphQL — `taktcli` adds **no**
84
- privilege surface. There is no claims/permission verb anywhere; admin ops are
85
- reachable via `gql` only if the caller's token already holds the `admin` claim.
86
- Long-lived bot tokens stay minted out-of-band (`tools/jwt_cli.py`) and dropped
87
- into `TAKT_TOKEN`; `login` is for humans and dev machines.
89
+ The stored grant refreshes itself: an access token lasts an hour and is renewed
90
+ 60 seconds before it expires (or once after the server rejects it), and the grant
91
+ lives as long as it is used at least once every 90 days. Refreshes take a lock on
92
+ `~/.takt/credentials.lock`, so parallel `taktcli` and `watch` processes share one
93
+ rotation. `logout` revokes the grant on the server before deleting it locally;
94
+ `logout --force` deletes even when the server can't be reached.
95
+
96
+ The file is mode `0600` in a `0700` directory and is replaced atomically;
97
+ taktcli refuses to read it through a symlink or with group/world permissions. A
98
+ `{ "token", "url" }` file from taktcli ≤ 0.6 keeps working until your next
99
+ `login` rewrites it.
100
+
101
+ `taktcli` adds **no** privilege surface: the token never exceeds what the
102
+ approving human can grant, there is no claims/permission verb anywhere, and admin
103
+ ops are reachable via `gql` only if the token already holds the `admin` claim.
104
+ Bots and harness images keep using `TAKT_TOKEN`.
88
105
 
89
106
  ## Passthrough — reaches 100% of GraphQL
90
107
 
@@ -27,8 +27,8 @@ the supported replacement for the retired `tools/wait_for_task.py`.
27
27
  ## Quickstart
28
28
 
29
29
  ```bash
30
- taktcli login # device-auth, stores ~/.takt/credentials.json
31
- taktcli whoami # nickname + claims + active credential source
30
+ taktcli login # browser approval, stores a self-refreshing profile
31
+ taktcli whoami # subject, owner, workspace, expiry, credential source
32
32
  taktcli board --project takt # show the board
33
33
  taktcli claim takt-42 # READY → RUNNING, prints leaseId
34
34
  taktcli run takt-42 # heartbeat the lease
@@ -42,33 +42,50 @@ lists a group's verbs.
42
42
 
43
43
  Token resolution, simplest-wins:
44
44
 
45
- 1. **`TAKT_TOKEN` env** → used verbatim. This is what harness containers are
46
- stuffed with; env always wins over any stored credential.
47
- 2. Else **`~/.takt/credentials.json`** — plain JSON `{ "token": "<jwt>", "url": "..." }`,
48
- mode `0600`, written by `taktcli login`.
45
+ 1. **`TAKT_TOKEN` env** → used verbatim and never refreshed. This is what harness
46
+ containers are stuffed with; env always wins over any stored credential.
47
+ 2. Else the selected **profile** in `~/.takt/credentials.json`, written by
48
+ `taktcli login`. The profile is `--profile NAME` → `TAKT_PROFILE` → the
49
+ store's current profile → `default`.
49
50
  3. Else error: `No Takt credentials found. Run: taktcli login` (exit `3`).
50
51
 
51
- URL resolution mirrors it: `TAKT_URL` env → `url` in the credentials file →
52
- default `https://api.takt.sh/graphql`. The `watch` WS endpoint is derived by
53
- swapping the scheme (`https → wss`).
52
+ URL resolution mirrors it: `TAKT_URL` env → the profile's URL → default
53
+ `https://api.takt.sh/graphql`. The `watch` WS endpoint is derived by swapping the
54
+ scheme (`https → wss`).
54
55
 
55
- `taktcli login` runs the OAuth 2.0 **device-authorization grant**: it prints a
56
- verification URL + user code, polls until you approve in a browser, then persists
57
- the token. `logout` removes the stored credential; `whoami` prints who you are
58
- and which credential source is active.
56
+ `taktcli login` runs the OAuth 2.0 **device grant** (RFC 8628). It prints
57
+ `Open <url> and enter <code>` to stderr, opens that page in your browser with the
58
+ code filled in (unless `--no-browser`, or there is no display), and waits for you
59
+ to approve. `--as <agent>` preselects an agent you own on the approval page, so
60
+ the CLI acts as that agent while you stay its owner.
59
61
 
60
62
  ```bash
61
- taktcli login # default endpoint
62
- taktcli login --url http://localhost:8454/graphql # override the endpoint
63
- taktcli logout
64
- taktcli whoami
63
+ taktcli login # you, profile "default"
64
+ taktcli login --as claude-code --profile claude-code
65
+ taktcli login --no-browser # headless: just print the URL + code
66
+ taktcli login --url http://localhost:8454/graphql # another server
67
+ taktcli whoami # subject, owner, client, workspace, expiry, source
68
+ taktcli profile list # stored profiles (no secrets)
69
+ taktcli profile use claude-code
70
+ taktcli logout # revoke the grant, then delete the profile
65
71
  ```
66
72
 
67
- Auth is the same JWT + claims used by MCP and GraphQL — `taktcli` adds **no**
68
- privilege surface. There is no claims/permission verb anywhere; admin ops are
69
- reachable via `gql` only if the caller's token already holds the `admin` claim.
70
- Long-lived bot tokens stay minted out-of-band (`tools/jwt_cli.py`) and dropped
71
- into `TAKT_TOKEN`; `login` is for humans and dev machines.
73
+ The stored grant refreshes itself: an access token lasts an hour and is renewed
74
+ 60 seconds before it expires (or once after the server rejects it), and the grant
75
+ lives as long as it is used at least once every 90 days. Refreshes take a lock on
76
+ `~/.takt/credentials.lock`, so parallel `taktcli` and `watch` processes share one
77
+ rotation. `logout` revokes the grant on the server before deleting it locally;
78
+ `logout --force` deletes even when the server can't be reached.
79
+
80
+ The file is mode `0600` in a `0700` directory and is replaced atomically;
81
+ taktcli refuses to read it through a symlink or with group/world permissions. A
82
+ `{ "token", "url" }` file from taktcli ≤ 0.6 keeps working until your next
83
+ `login` rewrites it.
84
+
85
+ `taktcli` adds **no** privilege surface: the token never exceeds what the
86
+ approving human can grant, there is no claims/permission verb anywhere, and admin
87
+ ops are reachable via `gql` only if the token already holds the `admin` claim.
88
+ Bots and harness images keep using `TAKT_TOKEN`.
72
89
 
73
90
  ## Passthrough — reaches 100% of GraphQL
74
91
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "taktcli"
3
- version = "0.6.2"
3
+ version = "0.7.0"
4
4
  description = "Authorized GraphQL CLI for Takt — full GraphQL surface from the shell, stdlib-only core"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -8,7 +8,7 @@ default 2 (which the contract reserves for transport failures).
8
8
  import argparse
9
9
  import sys
10
10
 
11
- from . import __version__, commands
11
+ from . import __version__, auth, commands
12
12
  from .errors import TaktCliError
13
13
 
14
14
  DOCS_HINT = (
@@ -34,6 +34,12 @@ def build_parser() -> TaktArgumentParser:
34
34
  epilog=DOCS_HINT,
35
35
  )
36
36
  parser.add_argument("--version", action="version", version=f"taktcli {__version__}")
37
+ parser.add_argument(
38
+ "--profile",
39
+ metavar="NAME",
40
+ default=None,
41
+ help="credential profile from ~/.takt/credentials.json (default: TAKT_PROFILE, else the current one)",
42
+ )
37
43
  subparsers = parser.add_subparsers(dest="command", metavar="<command>", required=True)
38
44
  commands.register(subparsers)
39
45
  help_parser = subparsers.add_parser("help", help="show this help message and exit")
@@ -49,6 +55,7 @@ def main(argv=None) -> int:
49
55
  if handler is None:
50
56
  parser.print_help(sys.stderr)
51
57
  return 3
58
+ auth.set_profile_override(getattr(args, "profile", None))
52
59
  try:
53
60
  result = handler(args)
54
61
  except TaktCliError as exc:
@@ -0,0 +1,188 @@
1
+ """Credential + URL resolution and silent refresh (PRD 002 §6, PRD-004 §9, [[takt-468]]).
2
+
3
+ Token: ``TAKT_TOKEN`` env (verbatim, never refreshed) -> the selected profile -> exit 3
4
+ Profile: ``--profile`` -> ``TAKT_PROFILE`` -> the store's ``current_profile`` -> ``default``
5
+ URL: ``TAKT_URL`` env -> the profile's ``url`` -> default
6
+
7
+ An explicit token never changes which URL is chosen. A profile that carries a refresh token is
8
+ refreshed 60s before its expiry and once more after a rejected request, under a cross-process
9
+ lock with a re-read: when another process already rotated, its tokens are used as they are.
10
+ """
11
+
12
+ import os
13
+ from dataclasses import dataclass
14
+
15
+ from . import oauth
16
+ from .credentials import DEFAULT_PROFILE, CredentialStore, expires_at_from, expiring
17
+ from .errors import ConfigError
18
+
19
+ DEFAULT_URL = "https://api.takt.sh/graphql"
20
+ LOGIN_HINT = "No Takt credentials found. Run: taktcli login"
21
+ REFRESH_LEEWAY_SECONDS = 60
22
+
23
+ _profile_override: str | None = None
24
+ _resolved_profiles: dict[str, str] = {}
25
+
26
+
27
+ def set_profile_override(name: str | None) -> None:
28
+ """Pin the profile for this process (the ``--profile`` flag); ``None`` clears it."""
29
+ global _profile_override
30
+ _profile_override = name or None
31
+
32
+
33
+ def profile_name(payload: dict | None = None) -> str:
34
+ """The selected profile: ``--profile`` -> ``TAKT_PROFILE`` -> current -> ``default``."""
35
+ explicit = _profile_override or os.environ.get("TAKT_PROFILE")
36
+ if explicit:
37
+ return explicit
38
+ if payload is not None and payload.get("current_profile"):
39
+ return payload["current_profile"]
40
+ return DEFAULT_PROFILE
41
+
42
+
43
+ def _profile_explicit() -> bool:
44
+ return bool(_profile_override or os.environ.get("TAKT_PROFILE"))
45
+
46
+
47
+ @dataclass
48
+ class Credential:
49
+ """The credential a command will use, and where it came from."""
50
+
51
+ url: str
52
+ token: str
53
+ source: str
54
+ profile_name: str | None = None
55
+ profile: dict | None = None
56
+
57
+ @property
58
+ def refreshable(self) -> bool:
59
+ return bool(self.profile and self.profile.get("refresh_token") and self.profile.get("origin"))
60
+
61
+
62
+ def _check_origin(url: str, name: str, profile: dict) -> None:
63
+ """Refuse to send an OAuth profile's tokens to a ``TAKT_URL`` on another origin."""
64
+ origin = profile.get("origin")
65
+ if origin and oauth.origin_from_url(url) != origin.rstrip("/"):
66
+ raise ConfigError(
67
+ f"Profile {name!r} was issued by {origin}, but TAKT_URL points at {url}. "
68
+ f"Unset TAKT_URL, pick another profile with --profile, or run: taktcli login --profile {name} --url {url}"
69
+ )
70
+
71
+
72
+ def select(store: CredentialStore | None = None) -> Credential:
73
+ """Resolve the active credential without refreshing it."""
74
+ env_url = os.environ.get("TAKT_URL")
75
+ env_token = os.environ.get("TAKT_TOKEN")
76
+ if env_token:
77
+ if env_url:
78
+ return Credential(env_url, env_token, "TAKT_TOKEN env")
79
+ store = store or CredentialStore()
80
+ payload = store.read(strict=False)
81
+ profile = payload["profiles"].get(profile_name(payload)) or {}
82
+ return Credential(profile.get("url") or DEFAULT_URL, env_token, "TAKT_TOKEN env")
83
+ store = store or CredentialStore()
84
+ payload = store.read()
85
+ name = profile_name(payload)
86
+ profile = payload["profiles"].get(name)
87
+ if not profile or not profile.get("access_token"):
88
+ if payload["profiles"] and (_profile_explicit() or name not in payload["profiles"]):
89
+ raise ConfigError(f"No Takt credentials for profile {name!r}. Run: taktcli login --profile {name}")
90
+ raise ConfigError(LOGIN_HINT)
91
+ url = env_url or profile.get("url") or DEFAULT_URL
92
+ if env_url:
93
+ _check_origin(env_url, name, profile)
94
+ return Credential(url, profile["access_token"], f"profile {name!r} ({store.path})", name, profile)
95
+
96
+
97
+ def refresh_profile(name: str, expected_token: str, *, store: CredentialStore | None = None, opener=None) -> dict:
98
+ """Rotate profile ``name``'s tokens under the cross-process lock; return the fresh profile.
99
+
100
+ Inside the lock the store is re-read: if another process already replaced
101
+ ``expected_token`` with a still-valid one, that is returned untouched. An ``invalid_grant``
102
+ (a replayed or revoked refresh token, PRD-004 D10) re-reads the store once more before
103
+ giving up with "run taktcli login" (exit 3).
104
+ """
105
+ store = store or CredentialStore()
106
+ with store.lock():
107
+ profile = store.read()["profiles"].get(name)
108
+ if not profile or not profile.get("access_token"):
109
+ raise ConfigError(f"Profile {name!r} was logged out. Run: taktcli login --profile {name}")
110
+ if profile["access_token"] != expected_token and not expiring(profile, REFRESH_LEEWAY_SECONDS):
111
+ return profile
112
+ if not profile.get("refresh_token") or not profile.get("origin"):
113
+ raise ConfigError(f"Profile {name!r} cannot be refreshed. Run: taktcli login --profile {name}")
114
+ try:
115
+ body = oauth.refresh(
116
+ profile["origin"], profile["refresh_token"], profile.get("client_id") or oauth.CLIENT_ID, opener=opener
117
+ )
118
+ except oauth.OAuthError as exc:
119
+ if exc.error == "invalid_grant":
120
+ current = store.read()["profiles"].get(name) or {}
121
+ if current.get("refresh_token") not in (None, profile["refresh_token"]) and not expiring(current, 0):
122
+ return current
123
+ raise ConfigError(
124
+ f"The session for profile {name!r} expired or was revoked. Run: taktcli login --profile {name}"
125
+ ) from exc
126
+ raise ConfigError(f"Token refresh failed: {exc}") from exc
127
+ fresh = apply_token_response(profile, body)
128
+ store.save_profile(name, fresh, make_current=False)
129
+ return fresh
130
+
131
+
132
+ def apply_token_response(profile: dict, body: dict) -> dict:
133
+ """``profile`` updated from an RFC 6749 §5.1 token response (rotated refresh token included)."""
134
+ try:
135
+ access_token = body["access_token"]
136
+ except KeyError as exc:
137
+ raise ConfigError("The token endpoint returned no access_token") from exc
138
+ fresh = dict(profile)
139
+ fresh["access_token"] = access_token
140
+ fresh["refresh_token"] = body.get("refresh_token") or profile.get("refresh_token")
141
+ fresh["expires_at"] = expires_at_from(body.get("expires_in"))
142
+ if body.get("scope") is not None:
143
+ fresh["scope"] = body["scope"]
144
+ return fresh
145
+
146
+
147
+ def resolve_credential(store: CredentialStore | None = None, opener=None) -> Credential:
148
+ """The active credential, refreshed first when it expires within 60s."""
149
+ store = store or CredentialStore()
150
+ cred = select(store)
151
+ if cred.refreshable and expiring(cred.profile, REFRESH_LEEWAY_SECONDS):
152
+ fresh = refresh_profile(cred.profile_name, cred.token, store=store, opener=opener)
153
+ cred = Credential(cred.url, fresh["access_token"], cred.source, cred.profile_name, fresh)
154
+ if cred.profile_name is not None:
155
+ _resolved_profiles[cred.token] = cred.profile_name
156
+ return cred
157
+
158
+
159
+ def resolve_auth() -> tuple[str, str]:
160
+ """``(url, token)`` for a GraphQL call, refreshing a stored OAuth profile when due."""
161
+ cred = resolve_credential()
162
+ return cred.url, cred.token
163
+
164
+
165
+ def reauthorize(rejected_token: str, *, store: CredentialStore | None = None, opener=None) -> str | None:
166
+ """A replacement for ``rejected_token`` after the server refused it, or ``None``.
167
+
168
+ Only a token this process resolved from a stored profile is replaced, and only from that
169
+ same profile, so a retry can never switch identity (``TAKT_TOKEN`` and anything not
170
+ resolved here get ``None``). When the profile already holds a newer token (another
171
+ process rotated) that one is returned; otherwise the profile is refreshed once.
172
+ """
173
+ name = _resolved_profiles.get(rejected_token)
174
+ if name is None or os.environ.get("TAKT_TOKEN"):
175
+ return None
176
+ store = store or CredentialStore()
177
+ profile = store.read()["profiles"].get(name) or {}
178
+ current = profile.get("access_token")
179
+ if not current:
180
+ return None
181
+ if current != rejected_token:
182
+ _resolved_profiles[current] = name
183
+ return current
184
+ if not (profile.get("refresh_token") and profile.get("origin")):
185
+ return None
186
+ fresh = refresh_profile(name, rejected_token, store=store, opener=opener)["access_token"]
187
+ _resolved_profiles[fresh] = name
188
+ return fresh
@@ -10,7 +10,7 @@ import argparse
10
10
  import os
11
11
  import sys
12
12
 
13
- from ..auth import resolve_auth
13
+ from ..auth import reauthorize, resolve_auth
14
14
  from ..errors import ConfigError
15
15
  from ..io import format_output, read_path
16
16
  from ..transport import execute
@@ -34,7 +34,7 @@ def add_output_args(parser: argparse.ArgumentParser) -> None:
34
34
  def fetch(document: str, variables: dict) -> dict:
35
35
  """Resolve auth, run the operation, and return the full GraphQL envelope."""
36
36
  url, token = resolve_auth()
37
- return execute(document, variables, url=url, token=token)
37
+ return execute(document, variables, url=url, token=token, reauthorize=reauthorize)
38
38
 
39
39
 
40
40
  def run_operation(args, document: str, variables: dict) -> int:
@@ -8,7 +8,7 @@ resolving an inline-or-file (``-`` = stdin) text argument.
8
8
 
9
9
  import sys
10
10
 
11
- from ..auth import resolve_auth
11
+ from ..auth import reauthorize, resolve_auth
12
12
  from ..errors import ConfigError
13
13
  from ..io import format_output, read_path
14
14
  from ..transport import execute
@@ -30,7 +30,7 @@ def run_op(args, document: str, variables: dict, *, hint=None, unwrap=None) -> i
30
30
  which still sees the full ``.data``.
31
31
  """
32
32
  url, token = resolve_auth()
33
- envelope = execute(document, variables, url=url, token=token)
33
+ envelope = execute(document, variables, url=url, token=token, reauthorize=reauthorize)
34
34
  print(format_output(
35
35
  envelope,
36
36
  raw=getattr(args, "raw", False),
@@ -52,7 +52,7 @@ def fetch_data(document: str, variables: dict) -> dict:
52
52
  resolving a label name to its id) without printing the intermediate result.
53
53
  """
54
54
  url, token = resolve_auth()
55
- envelope = execute(document, variables, url=url, token=token)
55
+ envelope = execute(document, variables, url=url, token=token, reauthorize=reauthorize)
56
56
  return (envelope or {}).get("data") or {}
57
57
 
58
58