chatenv 0.2.4__tar.gz → 0.2.6__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.6}/PKG-INFO +1 -1
  2. {chatenv-0.2.4 → chatenv-0.2.6}/src/chatenv/__init__.py +3 -1
  3. {chatenv-0.2.4 → chatenv-0.2.6}/src/chatenv/cli.py +185 -2
  4. {chatenv-0.2.4 → chatenv-0.2.6}/src/chatenv/paths.py +4 -0
  5. chatenv-0.2.6/src/chatenv/tokens.py +211 -0
  6. {chatenv-0.2.4 → chatenv-0.2.6/src/chatenv.egg-info}/PKG-INFO +1 -1
  7. {chatenv-0.2.4 → chatenv-0.2.6}/src/chatenv.egg-info/SOURCES.txt +1 -0
  8. {chatenv-0.2.4 → chatenv-0.2.6}/tests/test_chatenv_core.py +159 -0
  9. {chatenv-0.2.4 → chatenv-0.2.6}/tests/test_version.py +6 -1
  10. {chatenv-0.2.4 → chatenv-0.2.6}/LICENSE +0 -0
  11. {chatenv-0.2.4 → chatenv-0.2.6}/README.md +0 -0
  12. {chatenv-0.2.4 → chatenv-0.2.6}/pyproject.toml +0 -0
  13. {chatenv-0.2.4 → chatenv-0.2.6}/setup.cfg +0 -0
  14. {chatenv-0.2.4 → chatenv-0.2.6}/src/chatenv/configs.py +0 -0
  15. {chatenv-0.2.4 → chatenv-0.2.6}/src/chatenv/discovery.py +0 -0
  16. {chatenv-0.2.4 → chatenv-0.2.6}/src/chatenv/fields.py +0 -0
  17. {chatenv-0.2.4 → chatenv-0.2.6}/src/chatenv/paste.py +0 -0
  18. {chatenv-0.2.4 → chatenv-0.2.6}/src/chatenv/presets/__init__.py +0 -0
  19. {chatenv-0.2.4 → chatenv-0.2.6}/src/chatenv/registry.py +0 -0
  20. {chatenv-0.2.4 → chatenv-0.2.6}/src/chatenv/source_chain.py +0 -0
  21. {chatenv-0.2.4 → chatenv-0.2.6}/src/chatenv/store.py +0 -0
  22. {chatenv-0.2.4 → chatenv-0.2.6}/src/chatenv/utils.py +0 -0
  23. {chatenv-0.2.4 → chatenv-0.2.6}/src/chatenv.egg-info/dependency_links.txt +0 -0
  24. {chatenv-0.2.4 → chatenv-0.2.6}/src/chatenv.egg-info/entry_points.txt +0 -0
  25. {chatenv-0.2.4 → chatenv-0.2.6}/src/chatenv.egg-info/requires.txt +0 -0
  26. {chatenv-0.2.4 → chatenv-0.2.6}/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.6
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.6"
@@ -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,168 @@ 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
+ try:
663
+ payload = _token_store(ctx).write(
664
+ service,
665
+ profile,
666
+ values=values,
667
+ token_type=token_type,
668
+ summary=_parse_summary(summary),
669
+ expires_at=expires_at,
670
+ source="refresh",
671
+ )
672
+ except ValueError as exc:
673
+ raise click.ClickException(str(exc)) from exc
674
+ if output_format == "json":
675
+ _echo_json(payload)
676
+ else:
677
+ _render_token_status(payload)
678
+
679
+
680
+ @token_group.command(name="status")
681
+ @click.argument("service")
682
+ @click.argument("profile", required=False, default="default")
683
+ @click.option("--format", "output_format", type=click.Choice(["text", "json"]), default="text", show_default=True)
684
+ @click.pass_context
685
+ def token_status(ctx: click.Context, service: str, profile: str, output_format: str):
686
+ """Show safe runtime token metadata for SERVICE/PROFILE."""
687
+ try:
688
+ payload = _token_store(ctx).status(service, profile)
689
+ except ValueError as exc:
690
+ raise click.ClickException(str(exc)) from exc
691
+ if output_format == "json":
692
+ _echo_json(payload)
693
+ else:
694
+ _render_token_status(payload)
695
+
696
+
697
+ @token_group.command(name="list")
698
+ @click.argument("service", required=False)
699
+ @click.option("--format", "output_format", type=click.Choice(["text", "json"]), default="text", show_default=True)
700
+ @click.pass_context
701
+ def token_list(ctx: click.Context, service: str | None, output_format: str):
702
+ """List runtime token profiles grouped by service."""
703
+ payload = _token_store(ctx).list_tokens(service)
704
+ if output_format == "json":
705
+ _echo_json(payload)
706
+ return
707
+ if not payload:
708
+ click.echo(f"No tokens found under {_tokens_dir(ctx)}")
709
+ return
710
+ current_service = None
711
+ for item in payload:
712
+ service_name = str(item["service"])
713
+ if service_name != current_service:
714
+ if current_service is not None:
715
+ click.echo("")
716
+ click.echo(f"[{service_name}]")
717
+ current_service = service_name
718
+ suffix = " [present]" if item.get("token_present") else ""
719
+ click.echo(f"- {item['profile']}.json{suffix}")
720
+
721
+
722
+ @token_group.command(name="clear")
723
+ @click.argument("service")
724
+ @click.argument("profile", required=False, default="default")
725
+ @click.option("--execute", is_flag=True, help="Actually delete the token file. Omit for a dry run.")
726
+ @click.option("--format", "output_format", type=click.Choice(["text", "json"]), default="text", show_default=True)
727
+ @click.pass_context
728
+ def token_clear(ctx: click.Context, service: str, profile: str, execute: bool, output_format: str):
729
+ """Clear a generic runtime token file for SERVICE/PROFILE."""
730
+ try:
731
+ payload = _token_store(ctx).clear(service, profile, execute=execute)
732
+ except ValueError as exc:
733
+ raise click.ClickException(str(exc)) from exc
734
+ if output_format == "json":
735
+ _echo_json(payload)
736
+ else:
737
+ for key, value in payload.items():
738
+ if value not in (None, ""):
739
+ click.echo(f"{key}={value}")
740
+
741
+
559
742
  @cli.command(name="cat")
560
743
  @click.argument("name", required=False)
561
744
  @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,211 @@
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 _strict_token_profile_segment(value: str | None) -> str:
40
+ if value is None:
41
+ return DEFAULT_PROFILE
42
+ raw = str(value)
43
+ stripped = raw.strip()
44
+ if not stripped:
45
+ raise ValueError("token profile must not be empty")
46
+ if stripped != raw:
47
+ raise ValueError("token profile must not contain leading or trailing whitespace")
48
+ if stripped in {".", ".."}:
49
+ raise ValueError("token profile must not be '.' or '..'")
50
+ if "/" in stripped or "\\" in stripped:
51
+ raise ValueError("token profile must be a single path segment")
52
+ if re.search(r"[^A-Za-z0-9_.-]", stripped):
53
+ raise ValueError("token profile may contain only letters, numbers, dot, underscore, and hyphen")
54
+ if stripped.strip(".-") != stripped:
55
+ raise ValueError("token profile must not start or end with dot or hyphen")
56
+ return stripped
57
+
58
+
59
+ def normalize_service_name(value: str) -> str:
60
+ return _safe_segment(value, default="Service")
61
+
62
+
63
+ def normalize_token_profile(value: str | None) -> str:
64
+ return _strict_token_profile_segment(value)
65
+
66
+
67
+ def _coerce_mapping(value: dict[str, Any] | None) -> dict[str, Any]:
68
+ return dict(value) if isinstance(value, dict) else {}
69
+
70
+
71
+ class TokenStore:
72
+ """Generic JSON token store keyed by service/profile.
73
+
74
+ The ``values`` object is opaque and may contain secrets. ``status`` and
75
+ ``list_tokens`` intentionally expose only safe metadata and caller-supplied
76
+ summary fields.
77
+ """
78
+
79
+ def __init__(self, *, home: str | Path | None = None, tokens_dir: str | Path | None = None):
80
+ self.tokens_dir = Path(tokens_dir) if tokens_dir is not None else get_paths(home).tokens_dir
81
+
82
+ def token_path(self, service: str, profile: str | None = None) -> Path:
83
+ service_name = normalize_service_name(service)
84
+ profile_name = normalize_token_profile(profile)
85
+ return self.tokens_dir / service_name / f"{profile_name}.json"
86
+
87
+ def read(self, service: str, profile: str | None = None) -> dict[str, Any]:
88
+ path = self.token_path(service, profile)
89
+ try:
90
+ payload = json.loads(path.read_text(encoding="utf-8"))
91
+ except FileNotFoundError:
92
+ return {}
93
+ except (OSError, json.JSONDecodeError):
94
+ return {}
95
+ return payload if isinstance(payload, dict) else {}
96
+
97
+ def write(
98
+ self,
99
+ service: str,
100
+ profile: str | None = None,
101
+ *,
102
+ values: dict[str, Any],
103
+ token_type: str = DEFAULT_TOKEN_TYPE,
104
+ summary: dict[str, Any] | None = None,
105
+ expires_at: str | None = None,
106
+ source: str = "refresh",
107
+ ) -> dict[str, Any]:
108
+ if not isinstance(values, dict) or not values:
109
+ raise ValueError("values must be a non-empty JSON object")
110
+ service_name = normalize_service_name(service)
111
+ profile_name = normalize_token_profile(profile)
112
+ path = self.token_path(service_name, profile_name)
113
+ existing = self.read(service_name, profile_name)
114
+ now = _iso(_now())
115
+ payload = {
116
+ "schema_version": TOKEN_SCHEMA_VERSION,
117
+ "service": service_name,
118
+ "profile": profile_name,
119
+ "token_type": token_type or DEFAULT_TOKEN_TYPE,
120
+ "created_at": existing.get("created_at") or now,
121
+ "updated_at": now,
122
+ "expires_at": expires_at or "",
123
+ "source": source,
124
+ "summary": _coerce_mapping(summary),
125
+ "values": values,
126
+ }
127
+ path.parent.mkdir(parents=True, exist_ok=True)
128
+ fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent), text=True)
129
+ try:
130
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
131
+ json.dump(payload, handle, ensure_ascii=False, indent=2, sort_keys=True)
132
+ handle.write("\n")
133
+ os.replace(tmp_name, path)
134
+ finally:
135
+ if os.path.exists(tmp_name):
136
+ os.unlink(tmp_name)
137
+ return self.status(service_name, profile_name)
138
+
139
+ def status(self, service: str, profile: str | None = None) -> dict[str, Any]:
140
+ service_name = normalize_service_name(service)
141
+ profile_name = normalize_token_profile(profile)
142
+ path = self.token_path(service_name, profile_name)
143
+ payload = self.read(service_name, profile_name)
144
+ values = payload.get("values") if isinstance(payload, dict) else None
145
+ token_present = isinstance(values, dict) and bool(values)
146
+ summary = payload.get("summary") if isinstance(payload.get("summary"), dict) else {}
147
+ return {
148
+ "ok": True,
149
+ "service": service_name,
150
+ "profile": profile_name,
151
+ "token_type": payload.get("token_type") or DEFAULT_TOKEN_TYPE,
152
+ "token_file": str(path),
153
+ "token_file_exists": path.exists(),
154
+ "token_present": token_present,
155
+ "created_at": payload.get("created_at") or "",
156
+ "updated_at": payload.get("updated_at") or "",
157
+ "expires_at": payload.get("expires_at") or "",
158
+ "source": payload.get("source") or "",
159
+ "summary": summary,
160
+ }
161
+
162
+ def list_tokens(self, service: str | None = None) -> list[dict[str, Any]]:
163
+ roots: list[Path]
164
+ if service:
165
+ roots = [self.tokens_dir / normalize_service_name(service)]
166
+ elif self.tokens_dir.exists():
167
+ roots = sorted(path for path in self.tokens_dir.iterdir() if path.is_dir())
168
+ else:
169
+ roots = []
170
+ items: list[dict[str, Any]] = []
171
+ for root in roots:
172
+ for path in sorted(root.glob("*.json")):
173
+ items.append(self.status(root.name, path.stem))
174
+ return sorted(items, key=lambda item: (str(item["service"]), str(item["profile"])))
175
+
176
+ def clear(self, service: str, profile: str | None = None, *, execute: bool = False) -> dict[str, Any]:
177
+ service_name = normalize_service_name(service)
178
+ profile_name = normalize_token_profile(profile)
179
+ path = self.token_path(service_name, profile_name)
180
+ exists = path.exists()
181
+ if not execute:
182
+ return {
183
+ "ok": True,
184
+ "mutated": False,
185
+ "service": service_name,
186
+ "profile": profile_name,
187
+ "token_file": str(path),
188
+ "token_file_exists": exists,
189
+ "would_delete": str(path),
190
+ }
191
+ if exists:
192
+ path.unlink()
193
+ return {
194
+ "ok": True,
195
+ "mutated": exists,
196
+ "service": service_name,
197
+ "profile": profile_name,
198
+ "token_file": str(path),
199
+ "token_file_exists": False,
200
+ "deleted": exists,
201
+ }
202
+
203
+
204
+ __all__ = [
205
+ "DEFAULT_PROFILE",
206
+ "DEFAULT_TOKEN_TYPE",
207
+ "TOKEN_SCHEMA_VERSION",
208
+ "TokenStore",
209
+ "normalize_service_name",
210
+ "normalize_token_profile",
211
+ ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chatenv
3
- Version: 0.2.4
3
+ Version: 0.2.6
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,6 +1,8 @@
1
+ import json
1
2
  from pathlib import Path
2
3
  from types import SimpleNamespace
3
4
 
5
+ import pytest
4
6
  from click.testing import CliRunner
5
7
  import chatenv.discovery as discovery_module
6
8
  import chatenv.cli as cli_module
@@ -240,6 +242,99 @@ def test_paths_only_use_chatarch_home(monkeypatch, tmp_path):
240
242
  paths = get_paths()
241
243
  assert paths.home_dir == (tmp_path / "arch").resolve()
242
244
  assert paths.envs_dir == (tmp_path / "arch" / "envs").resolve()
245
+ assert paths.tokens_dir == (tmp_path / "arch" / "tokens").resolve()
246
+
247
+
248
+ def test_token_store_roundtrip_keeps_profile_parallel_to_env_without_exposing_values(tmp_path):
249
+ from chatenv.tokens import TokenStore
250
+
251
+ home = tmp_path / "arch"
252
+ store = TokenStore(home=home)
253
+
254
+ saved = store.write(
255
+ "CRS",
256
+ "admin",
257
+ values={"access_token": "opaque-value-a", "cookie": "opaque-value-b"},
258
+ token_type="admin_session",
259
+ summary={"base_url": "https://crs.example.test", "username": "operator"},
260
+ )
261
+
262
+ assert saved["service"] == "CRS"
263
+ assert saved["profile"] == "admin"
264
+ assert saved["token_present"] is True
265
+ assert saved["token_file"] == str(home / "tokens" / "CRS" / "admin.json")
266
+ assert "opaque-value-a" not in json.dumps(saved)
267
+
268
+ assert store.read("CRS", "admin")["values"] == {
269
+ "access_token": "opaque-value-a",
270
+ "cookie": "opaque-value-b",
271
+ }
272
+ status = store.status("CRS", "admin")
273
+ assert status["summary"] == {"base_url": "https://crs.example.test", "username": "operator"}
274
+ assert "opaque-value-a" not in json.dumps(status)
275
+ assert "opaque-value-b" not in json.dumps(status)
276
+
277
+
278
+ def test_token_store_list_and_clear_are_generic(tmp_path):
279
+ from chatenv.tokens import TokenStore
280
+
281
+ store = TokenStore(home=tmp_path / "arch")
282
+ store.write("PyPI", "RexWzh", values={"session": "opaque-value-a"}, token_type="web_session")
283
+ store.write("CRS", "admin", values={"access_token": "opaque-value-b"}, token_type="admin_session")
284
+
285
+ all_tokens = store.list_tokens()
286
+ assert [(item["service"], item["profile"]) for item in all_tokens] == [("CRS", "admin"), ("PyPI", "RexWzh")]
287
+ assert store.list_tokens("PyPI")[0]["profile"] == "RexWzh"
288
+
289
+ dry_run = store.clear("PyPI", "RexWzh", execute=False)
290
+ assert dry_run["would_delete"].endswith("tokens/PyPI/RexWzh.json")
291
+ assert store.status("PyPI", "RexWzh")["token_present"] is True
292
+
293
+ removed = store.clear("PyPI", "RexWzh", execute=True)
294
+ assert removed["deleted"] is True
295
+ assert store.status("PyPI", "RexWzh")["token_present"] is False
296
+
297
+
298
+ def test_token_store_rejects_aliasing_profile_segments(tmp_path):
299
+ from chatenv.tokens import TokenStore, normalize_token_profile
300
+
301
+ store = TokenStore(home=tmp_path / "arch")
302
+ store.write("PyPI", "RexWzh", values={"session": "opaque-value"}, token_type="web_session")
303
+
304
+ invalid_profiles = [
305
+ "",
306
+ " ",
307
+ ".",
308
+ "..",
309
+ "../RexWzh",
310
+ "RexWzh/other",
311
+ "RexWzh\\other",
312
+ " RexWzh",
313
+ "RexWzh ",
314
+ ".RexWzh",
315
+ "RexWzh.",
316
+ "Rex Wzh",
317
+ ]
318
+ for profile in invalid_profiles:
319
+ with pytest.raises(ValueError):
320
+ normalize_token_profile(profile)
321
+ with pytest.raises(ValueError):
322
+ store.status("PyPI", profile)
323
+
324
+ assert store.status("PyPI", "RexWzh")["token_present"] is True
325
+ assert store.status("PyPI", None)["profile"] == "default"
326
+
327
+
328
+ def test_token_cli_reports_invalid_profile_without_aliasing(tmp_path):
329
+ runner = CliRunner()
330
+ result = runner.invoke(
331
+ cli,
332
+ ["--home", str(tmp_path / "arch"), "token", "status", "PyPI", "../RexWzh", "--format", "json"],
333
+ )
334
+
335
+ assert result.exit_code != 0
336
+ assert "single path segment" in result.output
337
+ assert "Traceback" not in result.output
243
338
 
244
339
 
245
340
  def test_store_profile_roundtrip(tmp_path):
@@ -606,6 +701,69 @@ def test_cli_new_bad_type_shows_available_types_outside_interactive(tmp_path):
606
701
  assert "Unit (unit)" in result.output
607
702
 
608
703
 
704
+ def test_cli_token_refresh_status_list_and_clear_hide_values(tmp_path):
705
+ runner = CliRunner()
706
+ home = tmp_path / "arch"
707
+ token_json = json.dumps({"access_token": "opaque-value-a", "cookie": "opaque-value-b"})
708
+
709
+ refreshed = runner.invoke(
710
+ cli,
711
+ [
712
+ "--home",
713
+ str(home),
714
+ "token",
715
+ "refresh",
716
+ "PyPI",
717
+ "RexWzh",
718
+ "--token-type",
719
+ "web_session",
720
+ "--summary",
721
+ "username=RexWzh",
722
+ "--summary",
723
+ "base_url=https://pypi.org",
724
+ "--stdin",
725
+ "--format",
726
+ "json",
727
+ ],
728
+ input=token_json,
729
+ )
730
+
731
+ assert refreshed.exit_code == 0, refreshed.output
732
+ assert '"token_present": true' in refreshed.output
733
+ assert '"token_file"' in refreshed.output
734
+ assert "opaque-value-a" not in refreshed.output
735
+ assert "opaque-value-b" not in refreshed.output
736
+ assert (home / "tokens" / "PyPI" / "RexWzh.json").exists()
737
+
738
+ status = runner.invoke(
739
+ cli,
740
+ ["--home", str(home), "token", "status", "PyPI", "RexWzh", "--format", "json"],
741
+ )
742
+ assert status.exit_code == 0, status.output
743
+ assert '"profile": "RexWzh"' in status.output
744
+ assert '"username": "RexWzh"' in status.output
745
+ assert "opaque-value-a" not in status.output
746
+
747
+ listed = runner.invoke(cli, ["--home", str(home), "token", "list", "PyPI"])
748
+ assert listed.exit_code == 0, listed.output
749
+ assert "[PyPI]" in listed.output
750
+ assert "- RexWzh.json" in listed.output
751
+ assert "opaque-value-a" not in listed.output
752
+
753
+ dry_run = runner.invoke(cli, ["--home", str(home), "token", "clear", "PyPI", "RexWzh"])
754
+ assert dry_run.exit_code == 0, dry_run.output
755
+ assert "would_delete=" in dry_run.output
756
+ assert (home / "tokens" / "PyPI" / "RexWzh.json").exists()
757
+
758
+ cleared = runner.invoke(
759
+ cli,
760
+ ["--home", str(home), "token", "clear", "PyPI", "RexWzh", "--execute", "--format", "json"],
761
+ )
762
+ assert cleared.exit_code == 0, cleared.output
763
+ assert '"deleted": true' in cleared.output
764
+ assert not (home / "tokens" / "PyPI" / "RexWzh.json").exists()
765
+
766
+
609
767
  def test_cli_init_prompts_for_config_type_when_type_missing(
610
768
  tmp_path, monkeypatch
611
769
  ):
@@ -658,6 +816,7 @@ def test_cli_help_uses_stable_command_order():
658
816
  "use",
659
817
  "list",
660
818
  "status",
819
+ "token",
661
820
  "cat",
662
821
  "get",
663
822
  "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.6"
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