chatenv 0.2.4__tar.gz → 0.2.5__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 (26) hide show
  1. {chatenv-0.2.4/src/chatenv.egg-info → chatenv-0.2.5}/PKG-INFO +1 -1
  2. {chatenv-0.2.4 → chatenv-0.2.5}/src/chatenv/__init__.py +3 -1
  3. {chatenv-0.2.4 → chatenv-0.2.5}/src/chatenv/cli.py +176 -2
  4. {chatenv-0.2.4 → chatenv-0.2.5}/src/chatenv/paths.py +4 -0
  5. chatenv-0.2.5/src/chatenv/tokens.py +191 -0
  6. {chatenv-0.2.4 → chatenv-0.2.5/src/chatenv.egg-info}/PKG-INFO +1 -1
  7. {chatenv-0.2.4 → chatenv-0.2.5}/src/chatenv.egg-info/SOURCES.txt +1 -0
  8. {chatenv-0.2.4 → chatenv-0.2.5}/tests/test_chatenv_core.py +116 -0
  9. {chatenv-0.2.4 → chatenv-0.2.5}/tests/test_version.py +6 -1
  10. {chatenv-0.2.4 → chatenv-0.2.5}/LICENSE +0 -0
  11. {chatenv-0.2.4 → chatenv-0.2.5}/README.md +0 -0
  12. {chatenv-0.2.4 → chatenv-0.2.5}/pyproject.toml +0 -0
  13. {chatenv-0.2.4 → chatenv-0.2.5}/setup.cfg +0 -0
  14. {chatenv-0.2.4 → chatenv-0.2.5}/src/chatenv/configs.py +0 -0
  15. {chatenv-0.2.4 → chatenv-0.2.5}/src/chatenv/discovery.py +0 -0
  16. {chatenv-0.2.4 → chatenv-0.2.5}/src/chatenv/fields.py +0 -0
  17. {chatenv-0.2.4 → chatenv-0.2.5}/src/chatenv/paste.py +0 -0
  18. {chatenv-0.2.4 → chatenv-0.2.5}/src/chatenv/presets/__init__.py +0 -0
  19. {chatenv-0.2.4 → chatenv-0.2.5}/src/chatenv/registry.py +0 -0
  20. {chatenv-0.2.4 → chatenv-0.2.5}/src/chatenv/source_chain.py +0 -0
  21. {chatenv-0.2.4 → chatenv-0.2.5}/src/chatenv/store.py +0 -0
  22. {chatenv-0.2.4 → chatenv-0.2.5}/src/chatenv/utils.py +0 -0
  23. {chatenv-0.2.4 → chatenv-0.2.5}/src/chatenv.egg-info/dependency_links.txt +0 -0
  24. {chatenv-0.2.4 → chatenv-0.2.5}/src/chatenv.egg-info/entry_points.txt +0 -0
  25. {chatenv-0.2.4 → chatenv-0.2.5}/src/chatenv.egg-info/requires.txt +0 -0
  26. {chatenv-0.2.4 → chatenv-0.2.5}/src/chatenv.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chatenv
3
- Version: 0.2.4
3
+ Version: 0.2.5
4
4
  Summary: ChatArch typed environment profile manager
5
5
  Author-email: rexwzh <1073853456@qq.com>
6
6
  License-Expression: MIT
@@ -3,6 +3,7 @@
3
3
  from .fields import BaseEnvConfig, EnvField
4
4
  from .paths import ChatArchPaths, get_paths
5
5
  from .store import EnvStore
6
+ from .tokens import TokenStore
6
7
  from .configs import FeishuConfig, OpenAIConfig
7
8
  from .discovery import get_provider_configs, get_provider_errors, load_config_providers
8
9
 
@@ -13,6 +14,7 @@ __all__ = [
13
14
  "EnvStore",
14
15
  "FeishuConfig",
15
16
  "OpenAIConfig",
17
+ "TokenStore",
16
18
  "get_provider_configs",
17
19
  "get_provider_errors",
18
20
  "get_paths",
@@ -20,4 +22,4 @@ __all__ = [
20
22
  "__version__",
21
23
  ]
22
24
 
23
- __version__ = "0.2.4"
25
+ __version__ = "0.2.5"
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import json
3
4
  import sys
4
5
  import os
5
6
  from pathlib import Path
@@ -26,6 +27,7 @@ from .paste import iter_fields_for_values, parse_pasted_env_text
26
27
  from .paths import get_paths
27
28
  from .registry import resolve_config_types
28
29
  from .store import EnvStore
30
+ from .tokens import TokenStore
29
31
  from .utils import mask_secret
30
32
 
31
33
 
@@ -106,6 +108,7 @@ COMMAND_ORDER = [
106
108
  "use",
107
109
  "list",
108
110
  "status",
111
+ "token",
109
112
  "cat",
110
113
  "get",
111
114
  "set",
@@ -116,13 +119,19 @@ COMMAND_ORDER = [
116
119
 
117
120
 
118
121
  class OrderedGroup(click.Group):
122
+ command_order = COMMAND_ORDER
123
+
119
124
  def list_commands(self, ctx: click.Context) -> list[str]:
120
125
  commands = set(self.commands)
121
- ordered = [name for name in COMMAND_ORDER if name in commands]
126
+ ordered = [name for name in self.command_order if name in commands]
122
127
  ordered.extend(sorted(commands.difference(ordered)))
123
128
  return ordered
124
129
 
125
130
 
131
+ class TokenCommandGroup(OrderedGroup):
132
+ command_order = ["status", "refresh", "list", "clear"]
133
+
134
+
126
135
  def _format_metavar(name: str) -> str:
127
136
  return name.replace("_", "-").upper()
128
137
 
@@ -234,7 +243,11 @@ def cli(ctx: click.Context, home: Path | None):
234
243
  """Manage typed env profiles under $CHATARCH_HOME/envs."""
235
244
  load_config_providers()
236
245
  paths = get_paths(home)
237
- ctx.obj = {"paths": paths, "store": EnvStore(paths.envs_dir)}
246
+ ctx.obj = {
247
+ "paths": paths,
248
+ "store": EnvStore(paths.envs_dir),
249
+ "token_store": TokenStore(tokens_dir=paths.tokens_dir),
250
+ }
238
251
 
239
252
 
240
253
  def _store(ctx: click.Context) -> EnvStore:
@@ -245,6 +258,14 @@ def _envs_dir(ctx: click.Context) -> Path:
245
258
  return ctx.obj["paths"].envs_dir
246
259
 
247
260
 
261
+ def _token_store(ctx: click.Context) -> TokenStore:
262
+ return ctx.obj["token_store"]
263
+
264
+
265
+ def _tokens_dir(ctx: click.Context) -> Path:
266
+ return ctx.obj["paths"].tokens_dir
267
+
268
+
248
269
  def _matched_or_all(config_types: tuple[str, ...]) -> list[type[BaseEnvConfig]]:
249
270
  return _ordered_config_classes(resolve_config_types(config_types)) if config_types else _ordered_config_classes()
250
271
 
@@ -556,6 +577,159 @@ def status_env(ctx: click.Context, config_types: tuple[str, ...], detail: bool):
556
577
  click.echo(f"- {provider}: {exc}")
557
578
 
558
579
 
580
+ @cli.group(name="token", cls=TokenCommandGroup)
581
+ def token_group():
582
+ """Manage generic runtime token profiles."""
583
+ pass
584
+
585
+
586
+ def _echo_json(payload: object) -> None:
587
+ click.echo(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True))
588
+
589
+
590
+ def _parse_summary(items: tuple[str, ...]) -> dict[str, str]:
591
+ summary: dict[str, str] = {}
592
+ for item in items:
593
+ if "=" not in item:
594
+ raise click.ClickException("--summary values must use KEY=VALUE format.")
595
+ key, value = item.split("=", 1)
596
+ key = key.strip()
597
+ if not key:
598
+ raise click.ClickException("--summary key cannot be blank.")
599
+ summary[key] = value.strip()
600
+ return summary
601
+
602
+
603
+ def _render_token_status(payload: dict[str, object]) -> None:
604
+ for key in (
605
+ "service",
606
+ "profile",
607
+ "token_type",
608
+ "token_file",
609
+ "token_file_exists",
610
+ "token_present",
611
+ "created_at",
612
+ "updated_at",
613
+ "expires_at",
614
+ "source",
615
+ ):
616
+ value = payload.get(key)
617
+ if value not in (None, ""):
618
+ click.echo(f"{key}={value}")
619
+ summary = payload.get("summary")
620
+ if isinstance(summary, dict) and summary:
621
+ click.echo("summary:")
622
+ for key in sorted(summary):
623
+ click.echo(f"- {key}={summary[key]}")
624
+
625
+
626
+ def _read_token_values(*, read_stdin: bool, value_file: Path | None) -> dict[str, object]:
627
+ if read_stdin == bool(value_file):
628
+ raise click.ClickException("token refresh requires exactly one of --stdin or --file.")
629
+ text = sys.stdin.read() if read_stdin else value_file.read_text(encoding="utf-8") # type: ignore[union-attr]
630
+ try:
631
+ payload = json.loads(text)
632
+ except json.JSONDecodeError as exc:
633
+ raise click.ClickException(f"token payload must be a JSON object: {exc}") from exc
634
+ if not isinstance(payload, dict) or not payload:
635
+ raise click.ClickException("token payload must be a non-empty JSON object.")
636
+ return payload
637
+
638
+
639
+ @token_group.command(name="refresh")
640
+ @click.argument("service")
641
+ @click.argument("profile", required=False, default="default")
642
+ @click.option("--stdin", "read_stdin", is_flag=True, help="Read token JSON object from stdin.")
643
+ @click.option("--file", "value_file", type=click.Path(dir_okay=False, path_type=Path), help="Read token JSON object from a file.")
644
+ @click.option("--token-type", default="runtime", show_default=True, help="Caller-defined token/session type.")
645
+ @click.option("--summary", multiple=True, help="Safe status field in KEY=VALUE form. Values are printed by status/list.")
646
+ @click.option("--expires-at", default="", help="Optional caller-provided expiry timestamp.")
647
+ @click.option("--format", "output_format", type=click.Choice(["text", "json"]), default="text", show_default=True)
648
+ @click.pass_context
649
+ def token_refresh(
650
+ ctx: click.Context,
651
+ service: str,
652
+ profile: str,
653
+ read_stdin: bool,
654
+ value_file: Path | None,
655
+ token_type: str,
656
+ summary: tuple[str, ...],
657
+ expires_at: str,
658
+ output_format: str,
659
+ ):
660
+ """Write refreshed generic runtime token JSON for SERVICE/PROFILE."""
661
+ values = _read_token_values(read_stdin=read_stdin, value_file=value_file)
662
+ payload = _token_store(ctx).write(
663
+ service,
664
+ profile,
665
+ values=values,
666
+ token_type=token_type,
667
+ summary=_parse_summary(summary),
668
+ expires_at=expires_at,
669
+ source="refresh",
670
+ )
671
+ if output_format == "json":
672
+ _echo_json(payload)
673
+ else:
674
+ _render_token_status(payload)
675
+
676
+
677
+ @token_group.command(name="status")
678
+ @click.argument("service")
679
+ @click.argument("profile", required=False, default="default")
680
+ @click.option("--format", "output_format", type=click.Choice(["text", "json"]), default="text", show_default=True)
681
+ @click.pass_context
682
+ def token_status(ctx: click.Context, service: str, profile: str, output_format: str):
683
+ """Show safe runtime token metadata for SERVICE/PROFILE."""
684
+ payload = _token_store(ctx).status(service, profile)
685
+ if output_format == "json":
686
+ _echo_json(payload)
687
+ else:
688
+ _render_token_status(payload)
689
+
690
+
691
+ @token_group.command(name="list")
692
+ @click.argument("service", required=False)
693
+ @click.option("--format", "output_format", type=click.Choice(["text", "json"]), default="text", show_default=True)
694
+ @click.pass_context
695
+ def token_list(ctx: click.Context, service: str | None, output_format: str):
696
+ """List runtime token profiles grouped by service."""
697
+ payload = _token_store(ctx).list_tokens(service)
698
+ if output_format == "json":
699
+ _echo_json(payload)
700
+ return
701
+ if not payload:
702
+ click.echo(f"No tokens found under {_tokens_dir(ctx)}")
703
+ return
704
+ current_service = None
705
+ for item in payload:
706
+ service_name = str(item["service"])
707
+ if service_name != current_service:
708
+ if current_service is not None:
709
+ click.echo("")
710
+ click.echo(f"[{service_name}]")
711
+ current_service = service_name
712
+ suffix = " [present]" if item.get("token_present") else ""
713
+ click.echo(f"- {item['profile']}.json{suffix}")
714
+
715
+
716
+ @token_group.command(name="clear")
717
+ @click.argument("service")
718
+ @click.argument("profile", required=False, default="default")
719
+ @click.option("--execute", is_flag=True, help="Actually delete the token file. Omit for a dry run.")
720
+ @click.option("--format", "output_format", type=click.Choice(["text", "json"]), default="text", show_default=True)
721
+ @click.pass_context
722
+ def token_clear(ctx: click.Context, service: str, profile: str, execute: bool, output_format: str):
723
+ """Clear a generic runtime token file for SERVICE/PROFILE."""
724
+ payload = _token_store(ctx).clear(service, profile, execute=execute)
725
+ if output_format == "json":
726
+ _echo_json(payload)
727
+ else:
728
+ for key, value in payload.items():
729
+ if value not in (None, ""):
730
+ click.echo(f"{key}={value}")
731
+
732
+
559
733
  @cli.command(name="cat")
560
734
  @click.argument("name", required=False)
561
735
  @click.option("--no-mask", is_flag=True, help="Show sensitive values in plain text.")
@@ -13,6 +13,10 @@ class ChatArchPaths:
13
13
  def envs_dir(self) -> Path:
14
14
  return self.home_dir / "envs"
15
15
 
16
+ @property
17
+ def tokens_dir(self) -> Path:
18
+ return self.home_dir / "tokens"
19
+
16
20
 
17
21
  def _expand_path(value: str | Path) -> Path:
18
22
  return Path(value).expanduser().resolve()
@@ -0,0 +1,191 @@
1
+ """Generic runtime token store for ChatArch tools.
2
+
3
+ ChatEnv owns only the generic management substrate. It stores one runtime
4
+ state document per service/profile under ``$CHATARCH_HOME/tokens`` and never
5
+ interprets service-specific token semantics.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import re
13
+ import tempfile
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from .paths import get_paths
19
+
20
+ TOKEN_SCHEMA_VERSION = 1
21
+ DEFAULT_PROFILE = "default"
22
+ DEFAULT_TOKEN_TYPE = "runtime"
23
+
24
+
25
+ def _now() -> datetime:
26
+ return datetime.now(timezone.utc)
27
+
28
+
29
+ def _iso(value: datetime) -> str:
30
+ return value.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
31
+
32
+
33
+ def _safe_segment(value: str, *, default: str) -> str:
34
+ cleaned = re.sub(r"[^A-Za-z0-9_.-]+", "-", str(value or "").strip())
35
+ cleaned = cleaned.strip(".-")
36
+ return cleaned or default
37
+
38
+
39
+ def normalize_service_name(value: str) -> str:
40
+ return _safe_segment(value, default="Service")
41
+
42
+
43
+ def normalize_token_profile(value: str | None) -> str:
44
+ return _safe_segment(value or DEFAULT_PROFILE, default=DEFAULT_PROFILE)
45
+
46
+
47
+ def _coerce_mapping(value: dict[str, Any] | None) -> dict[str, Any]:
48
+ return dict(value) if isinstance(value, dict) else {}
49
+
50
+
51
+ class TokenStore:
52
+ """Generic JSON token store keyed by service/profile.
53
+
54
+ The ``values`` object is opaque and may contain secrets. ``status`` and
55
+ ``list_tokens`` intentionally expose only safe metadata and caller-supplied
56
+ summary fields.
57
+ """
58
+
59
+ def __init__(self, *, home: str | Path | None = None, tokens_dir: str | Path | None = None):
60
+ self.tokens_dir = Path(tokens_dir) if tokens_dir is not None else get_paths(home).tokens_dir
61
+
62
+ def token_path(self, service: str, profile: str | None = None) -> Path:
63
+ service_name = normalize_service_name(service)
64
+ profile_name = normalize_token_profile(profile)
65
+ return self.tokens_dir / service_name / f"{profile_name}.json"
66
+
67
+ def read(self, service: str, profile: str | None = None) -> dict[str, Any]:
68
+ path = self.token_path(service, profile)
69
+ try:
70
+ payload = json.loads(path.read_text(encoding="utf-8"))
71
+ except FileNotFoundError:
72
+ return {}
73
+ except (OSError, json.JSONDecodeError):
74
+ return {}
75
+ return payload if isinstance(payload, dict) else {}
76
+
77
+ def write(
78
+ self,
79
+ service: str,
80
+ profile: str | None = None,
81
+ *,
82
+ values: dict[str, Any],
83
+ token_type: str = DEFAULT_TOKEN_TYPE,
84
+ summary: dict[str, Any] | None = None,
85
+ expires_at: str | None = None,
86
+ source: str = "refresh",
87
+ ) -> dict[str, Any]:
88
+ if not isinstance(values, dict) or not values:
89
+ raise ValueError("values must be a non-empty JSON object")
90
+ service_name = normalize_service_name(service)
91
+ profile_name = normalize_token_profile(profile)
92
+ path = self.token_path(service_name, profile_name)
93
+ existing = self.read(service_name, profile_name)
94
+ now = _iso(_now())
95
+ payload = {
96
+ "schema_version": TOKEN_SCHEMA_VERSION,
97
+ "service": service_name,
98
+ "profile": profile_name,
99
+ "token_type": token_type or DEFAULT_TOKEN_TYPE,
100
+ "created_at": existing.get("created_at") or now,
101
+ "updated_at": now,
102
+ "expires_at": expires_at or "",
103
+ "source": source,
104
+ "summary": _coerce_mapping(summary),
105
+ "values": values,
106
+ }
107
+ path.parent.mkdir(parents=True, exist_ok=True)
108
+ fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent), text=True)
109
+ try:
110
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
111
+ json.dump(payload, handle, ensure_ascii=False, indent=2, sort_keys=True)
112
+ handle.write("\n")
113
+ os.replace(tmp_name, path)
114
+ finally:
115
+ if os.path.exists(tmp_name):
116
+ os.unlink(tmp_name)
117
+ return self.status(service_name, profile_name)
118
+
119
+ def status(self, service: str, profile: str | None = None) -> dict[str, Any]:
120
+ service_name = normalize_service_name(service)
121
+ profile_name = normalize_token_profile(profile)
122
+ path = self.token_path(service_name, profile_name)
123
+ payload = self.read(service_name, profile_name)
124
+ values = payload.get("values") if isinstance(payload, dict) else None
125
+ token_present = isinstance(values, dict) and bool(values)
126
+ summary = payload.get("summary") if isinstance(payload.get("summary"), dict) else {}
127
+ return {
128
+ "ok": True,
129
+ "service": service_name,
130
+ "profile": profile_name,
131
+ "token_type": payload.get("token_type") or DEFAULT_TOKEN_TYPE,
132
+ "token_file": str(path),
133
+ "token_file_exists": path.exists(),
134
+ "token_present": token_present,
135
+ "created_at": payload.get("created_at") or "",
136
+ "updated_at": payload.get("updated_at") or "",
137
+ "expires_at": payload.get("expires_at") or "",
138
+ "source": payload.get("source") or "",
139
+ "summary": summary,
140
+ }
141
+
142
+ def list_tokens(self, service: str | None = None) -> list[dict[str, Any]]:
143
+ roots: list[Path]
144
+ if service:
145
+ roots = [self.tokens_dir / normalize_service_name(service)]
146
+ elif self.tokens_dir.exists():
147
+ roots = sorted(path for path in self.tokens_dir.iterdir() if path.is_dir())
148
+ else:
149
+ roots = []
150
+ items: list[dict[str, Any]] = []
151
+ for root in roots:
152
+ for path in sorted(root.glob("*.json")):
153
+ items.append(self.status(root.name, path.stem))
154
+ return sorted(items, key=lambda item: (str(item["service"]), str(item["profile"])))
155
+
156
+ def clear(self, service: str, profile: str | None = None, *, execute: bool = False) -> dict[str, Any]:
157
+ service_name = normalize_service_name(service)
158
+ profile_name = normalize_token_profile(profile)
159
+ path = self.token_path(service_name, profile_name)
160
+ exists = path.exists()
161
+ if not execute:
162
+ return {
163
+ "ok": True,
164
+ "mutated": False,
165
+ "service": service_name,
166
+ "profile": profile_name,
167
+ "token_file": str(path),
168
+ "token_file_exists": exists,
169
+ "would_delete": str(path),
170
+ }
171
+ if exists:
172
+ path.unlink()
173
+ return {
174
+ "ok": True,
175
+ "mutated": exists,
176
+ "service": service_name,
177
+ "profile": profile_name,
178
+ "token_file": str(path),
179
+ "token_file_exists": False,
180
+ "deleted": exists,
181
+ }
182
+
183
+
184
+ __all__ = [
185
+ "DEFAULT_PROFILE",
186
+ "DEFAULT_TOKEN_TYPE",
187
+ "TOKEN_SCHEMA_VERSION",
188
+ "TokenStore",
189
+ "normalize_service_name",
190
+ "normalize_token_profile",
191
+ ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chatenv
3
- Version: 0.2.4
3
+ Version: 0.2.5
4
4
  Summary: ChatArch typed environment profile manager
5
5
  Author-email: rexwzh <1073853456@qq.com>
6
6
  License-Expression: MIT
@@ -11,6 +11,7 @@ src/chatenv/paths.py
11
11
  src/chatenv/registry.py
12
12
  src/chatenv/source_chain.py
13
13
  src/chatenv/store.py
14
+ src/chatenv/tokens.py
14
15
  src/chatenv/utils.py
15
16
  src/chatenv.egg-info/PKG-INFO
16
17
  src/chatenv.egg-info/SOURCES.txt
@@ -1,3 +1,4 @@
1
+ import json
1
2
  from pathlib import Path
2
3
  from types import SimpleNamespace
3
4
 
@@ -240,6 +241,57 @@ def test_paths_only_use_chatarch_home(monkeypatch, tmp_path):
240
241
  paths = get_paths()
241
242
  assert paths.home_dir == (tmp_path / "arch").resolve()
242
243
  assert paths.envs_dir == (tmp_path / "arch" / "envs").resolve()
244
+ assert paths.tokens_dir == (tmp_path / "arch" / "tokens").resolve()
245
+
246
+
247
+ def test_token_store_roundtrip_keeps_profile_parallel_to_env_without_exposing_values(tmp_path):
248
+ from chatenv.tokens import TokenStore
249
+
250
+ home = tmp_path / "arch"
251
+ store = TokenStore(home=home)
252
+
253
+ saved = store.write(
254
+ "CRS",
255
+ "admin",
256
+ values={"access_token": "opaque-value-a", "cookie": "opaque-value-b"},
257
+ token_type="admin_session",
258
+ summary={"base_url": "https://crs.example.test", "username": "operator"},
259
+ )
260
+
261
+ assert saved["service"] == "CRS"
262
+ assert saved["profile"] == "admin"
263
+ assert saved["token_present"] is True
264
+ assert saved["token_file"] == str(home / "tokens" / "CRS" / "admin.json")
265
+ assert "opaque-value-a" not in json.dumps(saved)
266
+
267
+ assert store.read("CRS", "admin")["values"] == {
268
+ "access_token": "opaque-value-a",
269
+ "cookie": "opaque-value-b",
270
+ }
271
+ status = store.status("CRS", "admin")
272
+ assert status["summary"] == {"base_url": "https://crs.example.test", "username": "operator"}
273
+ assert "opaque-value-a" not in json.dumps(status)
274
+ assert "opaque-value-b" not in json.dumps(status)
275
+
276
+
277
+ def test_token_store_list_and_clear_are_generic(tmp_path):
278
+ from chatenv.tokens import TokenStore
279
+
280
+ store = TokenStore(home=tmp_path / "arch")
281
+ store.write("PyPI", "RexWzh", values={"session": "opaque-value-a"}, token_type="web_session")
282
+ store.write("CRS", "admin", values={"access_token": "opaque-value-b"}, token_type="admin_session")
283
+
284
+ all_tokens = store.list_tokens()
285
+ assert [(item["service"], item["profile"]) for item in all_tokens] == [("CRS", "admin"), ("PyPI", "RexWzh")]
286
+ assert store.list_tokens("PyPI")[0]["profile"] == "RexWzh"
287
+
288
+ dry_run = store.clear("PyPI", "RexWzh", execute=False)
289
+ assert dry_run["would_delete"].endswith("tokens/PyPI/RexWzh.json")
290
+ assert store.status("PyPI", "RexWzh")["token_present"] is True
291
+
292
+ removed = store.clear("PyPI", "RexWzh", execute=True)
293
+ assert removed["deleted"] is True
294
+ assert store.status("PyPI", "RexWzh")["token_present"] is False
243
295
 
244
296
 
245
297
  def test_store_profile_roundtrip(tmp_path):
@@ -606,6 +658,69 @@ def test_cli_new_bad_type_shows_available_types_outside_interactive(tmp_path):
606
658
  assert "Unit (unit)" in result.output
607
659
 
608
660
 
661
+ def test_cli_token_refresh_status_list_and_clear_hide_values(tmp_path):
662
+ runner = CliRunner()
663
+ home = tmp_path / "arch"
664
+ token_json = json.dumps({"access_token": "opaque-value-a", "cookie": "opaque-value-b"})
665
+
666
+ refreshed = runner.invoke(
667
+ cli,
668
+ [
669
+ "--home",
670
+ str(home),
671
+ "token",
672
+ "refresh",
673
+ "PyPI",
674
+ "RexWzh",
675
+ "--token-type",
676
+ "web_session",
677
+ "--summary",
678
+ "username=RexWzh",
679
+ "--summary",
680
+ "base_url=https://pypi.org",
681
+ "--stdin",
682
+ "--format",
683
+ "json",
684
+ ],
685
+ input=token_json,
686
+ )
687
+
688
+ assert refreshed.exit_code == 0, refreshed.output
689
+ assert '"token_present": true' in refreshed.output
690
+ assert '"token_file"' in refreshed.output
691
+ assert "opaque-value-a" not in refreshed.output
692
+ assert "opaque-value-b" not in refreshed.output
693
+ assert (home / "tokens" / "PyPI" / "RexWzh.json").exists()
694
+
695
+ status = runner.invoke(
696
+ cli,
697
+ ["--home", str(home), "token", "status", "PyPI", "RexWzh", "--format", "json"],
698
+ )
699
+ assert status.exit_code == 0, status.output
700
+ assert '"profile": "RexWzh"' in status.output
701
+ assert '"username": "RexWzh"' in status.output
702
+ assert "opaque-value-a" not in status.output
703
+
704
+ listed = runner.invoke(cli, ["--home", str(home), "token", "list", "PyPI"])
705
+ assert listed.exit_code == 0, listed.output
706
+ assert "[PyPI]" in listed.output
707
+ assert "- RexWzh.json" in listed.output
708
+ assert "opaque-value-a" not in listed.output
709
+
710
+ dry_run = runner.invoke(cli, ["--home", str(home), "token", "clear", "PyPI", "RexWzh"])
711
+ assert dry_run.exit_code == 0, dry_run.output
712
+ assert "would_delete=" in dry_run.output
713
+ assert (home / "tokens" / "PyPI" / "RexWzh.json").exists()
714
+
715
+ cleared = runner.invoke(
716
+ cli,
717
+ ["--home", str(home), "token", "clear", "PyPI", "RexWzh", "--execute", "--format", "json"],
718
+ )
719
+ assert cleared.exit_code == 0, cleared.output
720
+ assert '"deleted": true' in cleared.output
721
+ assert not (home / "tokens" / "PyPI" / "RexWzh.json").exists()
722
+
723
+
609
724
  def test_cli_init_prompts_for_config_type_when_type_missing(
610
725
  tmp_path, monkeypatch
611
726
  ):
@@ -658,6 +773,7 @@ def test_cli_help_uses_stable_command_order():
658
773
  "use",
659
774
  "list",
660
775
  "status",
776
+ "token",
661
777
  "cat",
662
778
  "get",
663
779
  "set",
@@ -4,7 +4,7 @@ from click.testing import CliRunner
4
4
 
5
5
 
6
6
  def test_version_present():
7
- assert __version__ == "0.2.4"
7
+ assert __version__ == "0.2.5"
8
8
 
9
9
 
10
10
  def test_cli_version_option():
@@ -35,6 +35,11 @@ def test_tree_option_renders_registered_command_surface():
35
35
  assert "├── use [NAME]" in result.output
36
36
  assert "├── list" in result.output
37
37
  assert "├── status" in result.output
38
+ assert "├── token" in result.output
39
+ assert "│ ├── refresh" in result.output
40
+ assert "│ ├── status" in result.output
41
+ assert "│ ├── list" in result.output
42
+ assert "│ └── clear" in result.output
38
43
  assert "├── cat [NAME]" in result.output
39
44
  assert "├── get [KEY]" in result.output
40
45
  assert "├── set [KEY-VALUE]" in result.output
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes