chatenv 0.2.3__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 (27) hide show
  1. {chatenv-0.2.3/src/chatenv.egg-info → chatenv-0.2.5}/PKG-INFO +12 -6
  2. {chatenv-0.2.3 → chatenv-0.2.5}/README.md +3 -0
  3. {chatenv-0.2.3 → chatenv-0.2.5}/pyproject.toml +10 -5
  4. {chatenv-0.2.3 → chatenv-0.2.5}/src/chatenv/__init__.py +3 -1
  5. {chatenv-0.2.3 → chatenv-0.2.5}/src/chatenv/cli.py +279 -2
  6. {chatenv-0.2.3 → chatenv-0.2.5}/src/chatenv/paths.py +4 -0
  7. chatenv-0.2.5/src/chatenv/tokens.py +191 -0
  8. {chatenv-0.2.3 → chatenv-0.2.5/src/chatenv.egg-info}/PKG-INFO +12 -6
  9. {chatenv-0.2.3 → chatenv-0.2.5}/src/chatenv.egg-info/SOURCES.txt +1 -0
  10. {chatenv-0.2.3 → chatenv-0.2.5}/src/chatenv.egg-info/requires.txt +5 -5
  11. {chatenv-0.2.3 → chatenv-0.2.5}/tests/test_chatenv_core.py +116 -0
  12. chatenv-0.2.5/tests/test_version.py +49 -0
  13. chatenv-0.2.3/tests/test_version.py +0 -14
  14. {chatenv-0.2.3 → chatenv-0.2.5}/LICENSE +0 -0
  15. {chatenv-0.2.3 → chatenv-0.2.5}/setup.cfg +0 -0
  16. {chatenv-0.2.3 → chatenv-0.2.5}/src/chatenv/configs.py +0 -0
  17. {chatenv-0.2.3 → chatenv-0.2.5}/src/chatenv/discovery.py +0 -0
  18. {chatenv-0.2.3 → chatenv-0.2.5}/src/chatenv/fields.py +0 -0
  19. {chatenv-0.2.3 → chatenv-0.2.5}/src/chatenv/paste.py +0 -0
  20. {chatenv-0.2.3 → chatenv-0.2.5}/src/chatenv/presets/__init__.py +0 -0
  21. {chatenv-0.2.3 → chatenv-0.2.5}/src/chatenv/registry.py +0 -0
  22. {chatenv-0.2.3 → chatenv-0.2.5}/src/chatenv/source_chain.py +0 -0
  23. {chatenv-0.2.3 → chatenv-0.2.5}/src/chatenv/store.py +0 -0
  24. {chatenv-0.2.3 → chatenv-0.2.5}/src/chatenv/utils.py +0 -0
  25. {chatenv-0.2.3 → chatenv-0.2.5}/src/chatenv.egg-info/dependency_links.txt +0 -0
  26. {chatenv-0.2.3 → chatenv-0.2.5}/src/chatenv.egg-info/entry_points.txt +0 -0
  27. {chatenv-0.2.3 → chatenv-0.2.5}/src/chatenv.egg-info/top_level.txt +0 -0
@@ -1,9 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chatenv
3
- Version: 0.2.3
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
7
+ Project-URL: Homepage, https://github.com/ChatArch/ChatEnv
8
+ Project-URL: Repository, https://github.com/ChatArch/ChatEnv
9
+ Project-URL: Documentation, https://chatarch.github.io/ChatEnv/
7
10
  Keywords: chatenv,chatarch,env
8
11
  Classifier: Programming Language :: Python :: 3
9
12
  Classifier: Operating System :: OS Independent
@@ -18,11 +21,11 @@ Requires-Dist: build>=1.0; extra == "dev"
18
21
  Requires-Dist: pytest>=8.0; extra == "dev"
19
22
  Requires-Dist: twine>=5.0; extra == "dev"
20
23
  Provides-Extra: docs
21
- Requires-Dist: mkdocs>=1.6; extra == "docs"
22
- Requires-Dist: mkdocs-material>=9.5; extra == "docs"
23
- Requires-Dist: mkdocs-minify-plugin>=0.8; extra == "docs"
24
- Requires-Dist: mkdocs-redirects>=1.2; extra == "docs"
25
- Requires-Dist: mike>=2.1; extra == "docs"
24
+ Requires-Dist: mkdocs<2.0,>=1.6; extra == "docs"
25
+ Requires-Dist: mkdocs-material<9.7,>=9.5; extra == "docs"
26
+ Requires-Dist: mkdocs-minify-plugin<1.0,>=0.8; extra == "docs"
27
+ Requires-Dist: mkdocs-redirects<2.0,>=1.2; extra == "docs"
28
+ Requires-Dist: mike<3.0,>=2.1; extra == "docs"
26
29
  Dynamic: license-file
27
30
 
28
31
  <div align="center">
@@ -87,6 +90,7 @@ $CHATARCH_HOME/envs/
87
90
 
88
91
  ```bash
89
92
  chatenv --version
93
+ chatenv --tree
90
94
  chatenv init -t example
91
95
  chatenv status
92
96
  chatenv status --detail
@@ -114,6 +118,8 @@ export CHATARCH_AUTO_PROMPT=false
114
118
 
115
119
  此时只有缺少必要参数的命令会直接报错;参数已经足够的命令行为不变。
116
120
 
121
+ `chatenv --tree` 可从当前安装包的 Click 注册表实时输出完整命令树,用于验收和脚本 readback。
122
+
117
123
  更多用法见 `docs/cli.md`。
118
124
 
119
125
  ## Paste
@@ -60,6 +60,7 @@ $CHATARCH_HOME/envs/
60
60
 
61
61
  ```bash
62
62
  chatenv --version
63
+ chatenv --tree
63
64
  chatenv init -t example
64
65
  chatenv status
65
66
  chatenv status --detail
@@ -87,6 +88,8 @@ export CHATARCH_AUTO_PROMPT=false
87
88
 
88
89
  此时只有缺少必要参数的命令会直接报错;参数已经足够的命令行为不变。
89
90
 
91
+ `chatenv --tree` 可从当前安装包的 Click 注册表实时输出完整命令树,用于验收和脚本 readback。
92
+
90
93
  更多用法见 `docs/cli.md`。
91
94
 
92
95
  ## Paste
@@ -21,6 +21,11 @@ classifiers = [
21
21
  "Operating System :: OS Independent",
22
22
  ]
23
23
 
24
+ [project.urls]
25
+ Homepage = "https://github.com/ChatArch/ChatEnv"
26
+ Repository = "https://github.com/ChatArch/ChatEnv"
27
+ Documentation = "https://chatarch.github.io/ChatEnv/"
28
+
24
29
  [tool.setuptools.dynamic]
25
30
  version = {attr = "chatenv.__version__"}
26
31
 
@@ -40,9 +45,9 @@ dev = [
40
45
  "twine>=5.0",
41
46
  ]
42
47
  docs = [
43
- "mkdocs>=1.6",
44
- "mkdocs-material>=9.5",
45
- "mkdocs-minify-plugin>=0.8",
46
- "mkdocs-redirects>=1.2",
47
- "mike>=2.1",
48
+ "mkdocs>=1.6,<2.0",
49
+ "mkdocs-material>=9.5,<9.7",
50
+ "mkdocs-minify-plugin>=0.8,<1.0",
51
+ "mkdocs-redirects>=1.2,<2.0",
52
+ "mike>=2.1,<3.0",
48
53
  ]
@@ -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.3"
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,22 +119,135 @@ 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
+
135
+ def _format_metavar(name: str) -> str:
136
+ return name.replace("_", "-").upper()
137
+
138
+
139
+ def _format_argument(param: click.Argument) -> str:
140
+ metavar = _format_metavar(param.name or "ARG")
141
+ if not param.required:
142
+ return f"[{metavar}]"
143
+ if param.nargs == -1:
144
+ return f"<{metavar}...>"
145
+ if param.nargs and param.nargs > 1:
146
+ return " ".join(f"<{metavar}>" for _ in range(param.nargs))
147
+ return f"<{metavar}>"
148
+
149
+
150
+ def _option_name(param: click.Option) -> str:
151
+ long_opts = [opt for opt in param.opts if opt.startswith("--")]
152
+ if long_opts:
153
+ return long_opts[0]
154
+ return param.opts[0] if param.opts else _format_metavar(param.name or "OPTION")
155
+
156
+
157
+ def _format_option(param: click.Option) -> str:
158
+ name = _option_name(param)
159
+ secondary_long = next(
160
+ (opt for opt in param.secondary_opts if opt.startswith("--")),
161
+ None,
162
+ )
163
+ if param.is_bool_flag and secondary_long:
164
+ return f"[{name}/{secondary_long}]"
165
+ if param.is_flag:
166
+ return f"[{name}]"
167
+ metavar = param.metavar or _format_metavar(param.name or "VALUE")
168
+ return f"[{name} <{metavar}>]"
169
+
170
+
171
+ def _command_signature(command: click.Command) -> str:
172
+ parts: list[str] = []
173
+ for param in command.params:
174
+ if isinstance(param, click.Argument):
175
+ parts.append(_format_argument(param))
176
+ for param in command.params:
177
+ if isinstance(param, click.Option):
178
+ if param.name in {"help", "version", "tree"}:
179
+ continue
180
+ parts.append(_format_option(param))
181
+ return " " + " ".join(parts) if parts else ""
182
+
183
+
184
+ def _short_help(command: click.Command) -> str:
185
+ return command.short_help or (command.help or "No description.").strip().splitlines()[0]
186
+
187
+
188
+ def _group_items(group: click.Group) -> list[tuple[str, str | click.Command]]:
189
+ ctx = click.Context(group, resilient_parsing=True)
190
+ return [(name, group.commands[name]) for name in group.list_commands(ctx)]
191
+
192
+
193
+ def render_cli_tree(root: click.Group | None = None) -> str:
194
+ """Render the registered Click command tree for `chatenv --tree`."""
195
+ if root is None:
196
+ root = cli
197
+ lines = [f"chatenv{_command_signature(root)} # {_short_help(root)}"]
198
+ root_items: list[tuple[str, str | click.Command]] = [
199
+ ("--help", "Show this help message."),
200
+ ("--version", "Show the installed package version."),
201
+ ("--tree", "Print the registered command tree."),
202
+ *_group_items(root),
203
+ ]
204
+
205
+ def walk(items: list[tuple[str, str | click.Command]], prefix: str = "") -> None:
206
+ for index, (name, item) in enumerate(items):
207
+ is_last = index == len(items) - 1
208
+ connector = "└── " if is_last else "├── "
209
+ child_prefix = prefix + (" " if is_last else "│ ")
210
+ if isinstance(item, str):
211
+ lines.append(f"{prefix}{connector}{name} # {item}")
212
+ continue
213
+ lines.append(f"{prefix}{connector}{name}{_command_signature(item)} # {_short_help(item)}")
214
+ if isinstance(item, click.Group) and item.commands:
215
+ walk(_group_items(item), child_prefix)
216
+
217
+ walk(root_items)
218
+ return "\n".join(lines)
219
+
220
+
221
+ def _tree_callback(ctx: click.Context, _param: click.Option, value: bool) -> None:
222
+ if not value or ctx.resilient_parsing:
223
+ return
224
+ if not isinstance(ctx.command, click.Group):
225
+ raise click.ClickException("--tree is only available on command groups")
226
+ click.echo(render_cli_tree(ctx.command))
227
+ ctx.exit()
228
+
229
+
126
230
  @click.group(name="chatenv", cls=OrderedGroup)
127
231
  @click.version_option(__version__, prog_name="chatenv")
232
+ @click.option(
233
+ "--tree",
234
+ is_flag=True,
235
+ is_eager=True,
236
+ expose_value=False,
237
+ callback=_tree_callback,
238
+ help="Print the registered command tree.",
239
+ )
128
240
  @click.option("--home", type=click.Path(file_okay=False, path_type=Path), help="Override CHATARCH_HOME for this command.")
129
241
  @click.pass_context
130
242
  def cli(ctx: click.Context, home: Path | None):
131
243
  """Manage typed env profiles under $CHATARCH_HOME/envs."""
132
244
  load_config_providers()
133
245
  paths = get_paths(home)
134
- 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
+ }
135
251
 
136
252
 
137
253
  def _store(ctx: click.Context) -> EnvStore:
@@ -142,6 +258,14 @@ def _envs_dir(ctx: click.Context) -> Path:
142
258
  return ctx.obj["paths"].envs_dir
143
259
 
144
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
+
145
269
  def _matched_or_all(config_types: tuple[str, ...]) -> list[type[BaseEnvConfig]]:
146
270
  return _ordered_config_classes(resolve_config_types(config_types)) if config_types else _ordered_config_classes()
147
271
 
@@ -453,6 +577,159 @@ def status_env(ctx: click.Context, config_types: tuple[str, ...], detail: bool):
453
577
  click.echo(f"- {provider}: {exc}")
454
578
 
455
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
+
456
733
  @cli.command(name="cat")
457
734
  @click.argument("name", required=False)
458
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,9 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chatenv
3
- Version: 0.2.3
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
7
+ Project-URL: Homepage, https://github.com/ChatArch/ChatEnv
8
+ Project-URL: Repository, https://github.com/ChatArch/ChatEnv
9
+ Project-URL: Documentation, https://chatarch.github.io/ChatEnv/
7
10
  Keywords: chatenv,chatarch,env
8
11
  Classifier: Programming Language :: Python :: 3
9
12
  Classifier: Operating System :: OS Independent
@@ -18,11 +21,11 @@ Requires-Dist: build>=1.0; extra == "dev"
18
21
  Requires-Dist: pytest>=8.0; extra == "dev"
19
22
  Requires-Dist: twine>=5.0; extra == "dev"
20
23
  Provides-Extra: docs
21
- Requires-Dist: mkdocs>=1.6; extra == "docs"
22
- Requires-Dist: mkdocs-material>=9.5; extra == "docs"
23
- Requires-Dist: mkdocs-minify-plugin>=0.8; extra == "docs"
24
- Requires-Dist: mkdocs-redirects>=1.2; extra == "docs"
25
- Requires-Dist: mike>=2.1; extra == "docs"
24
+ Requires-Dist: mkdocs<2.0,>=1.6; extra == "docs"
25
+ Requires-Dist: mkdocs-material<9.7,>=9.5; extra == "docs"
26
+ Requires-Dist: mkdocs-minify-plugin<1.0,>=0.8; extra == "docs"
27
+ Requires-Dist: mkdocs-redirects<2.0,>=1.2; extra == "docs"
28
+ Requires-Dist: mike<3.0,>=2.1; extra == "docs"
26
29
  Dynamic: license-file
27
30
 
28
31
  <div align="center">
@@ -87,6 +90,7 @@ $CHATARCH_HOME/envs/
87
90
 
88
91
  ```bash
89
92
  chatenv --version
93
+ chatenv --tree
90
94
  chatenv init -t example
91
95
  chatenv status
92
96
  chatenv status --detail
@@ -114,6 +118,8 @@ export CHATARCH_AUTO_PROMPT=false
114
118
 
115
119
  此时只有缺少必要参数的命令会直接报错;参数已经足够的命令行为不变。
116
120
 
121
+ `chatenv --tree` 可从当前安装包的 Click 注册表实时输出完整命令树,用于验收和脚本 readback。
122
+
117
123
  更多用法见 `docs/cli.md`。
118
124
 
119
125
  ## Paste
@@ -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
@@ -8,8 +8,8 @@ pytest>=8.0
8
8
  twine>=5.0
9
9
 
10
10
  [docs]
11
- mkdocs>=1.6
12
- mkdocs-material>=9.5
13
- mkdocs-minify-plugin>=0.8
14
- mkdocs-redirects>=1.2
15
- mike>=2.1
11
+ mkdocs<2.0,>=1.6
12
+ mkdocs-material<9.7,>=9.5
13
+ mkdocs-minify-plugin<1.0,>=0.8
14
+ mkdocs-redirects<2.0,>=1.2
15
+ mike<3.0,>=2.1
@@ -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",
@@ -0,0 +1,49 @@
1
+ from chatenv import __version__
2
+ from chatenv.cli import cli
3
+ from click.testing import CliRunner
4
+
5
+
6
+ def test_version_present():
7
+ assert __version__ == "0.2.5"
8
+
9
+
10
+ def test_cli_version_option():
11
+ result = CliRunner().invoke(cli, ["--version"])
12
+
13
+ assert result.exit_code == 0
14
+ assert __version__ in result.output
15
+
16
+
17
+ def test_help_lists_tree_option():
18
+ result = CliRunner().invoke(cli, ["--help"])
19
+
20
+ assert result.exit_code == 0
21
+ assert "--tree" in result.output
22
+
23
+
24
+ def test_tree_option_renders_registered_command_surface():
25
+ result = CliRunner().invoke(cli, ["--tree"])
26
+
27
+ assert result.exit_code == 0
28
+ assert "chatenv [--home <HOME>] # Manage typed env profiles" in result.output
29
+ assert "├── --help" in result.output
30
+ assert "├── --version" in result.output
31
+ assert "├── --tree" in result.output
32
+ assert "├── init" in result.output
33
+ assert "├── new [NAME]" in result.output
34
+ assert "├── paste" in result.output
35
+ assert "├── use [NAME]" in result.output
36
+ assert "├── list" in result.output
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
43
+ assert "├── cat [NAME]" in result.output
44
+ assert "├── get [KEY]" in result.output
45
+ assert "├── set [KEY-VALUE]" in result.output
46
+ assert "├── save [NAME]" in result.output
47
+ assert "├── delete [NAME]" in result.output
48
+ assert "└── test" in result.output
49
+ assert "hello" not in result.output.lower()
@@ -1,14 +0,0 @@
1
- from chatenv import __version__
2
- from chatenv.cli import cli
3
- from click.testing import CliRunner
4
-
5
-
6
- def test_version_present():
7
- assert __version__ == "0.2.3"
8
-
9
-
10
- def test_cli_version_option():
11
- result = CliRunner().invoke(cli, ["--version"])
12
-
13
- assert result.exit_code == 0
14
- assert __version__ 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