keystone-cli 0.1.10__tar.gz → 0.1.26__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 (134) hide show
  1. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/PKG-INFO +2 -2
  2. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/pyproject.toml +3 -3
  3. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/src/keystone_cli/__main__.py +10 -0
  4. keystone_cli-0.1.26/src/keystone_cli/_stdio.py +68 -0
  5. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/src/keystone_cli/auth/token_store.py +53 -5
  6. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/src/keystone_cli/commands/agent.py +345 -11
  7. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/src/keystone_cli/commands/dev.py +1 -0
  8. keystone_cli-0.1.26/src/keystone_cli/commands/kb.py +144 -0
  9. keystone_cli-0.1.26/src/keystone_cli/commands/tool_credentials.py +153 -0
  10. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/src/keystone_cli/devloop.py +38 -6
  11. keystone_cli-0.1.26/src/keystone_cli/runner.py +595 -0
  12. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/src/keystone_cli/scaffold.py +32 -7
  13. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/src/keystone_cli/templates/blank/agent.yaml.tmpl +3 -0
  14. keystone_cli-0.1.26/src/keystone_cli/templates/guardrails/README.md.tmpl +63 -0
  15. keystone_cli-0.1.26/src/keystone_cli/templates/guardrails/agent.yaml.tmpl +53 -0
  16. keystone_cli-0.1.26/src/keystone_cli/templates/guardrails/pkg/graph.py.tmpl +102 -0
  17. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/src/keystone_cli/templates/hitl/README.md.tmpl +13 -1
  18. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/src/keystone_cli/templates/hitl/agent.yaml.tmpl +8 -0
  19. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/src/keystone_cli/templates/hitl/pkg/graph.py.tmpl +13 -13
  20. keystone_cli-0.1.26/src/keystone_cli/templates/kb-builder/README.md.tmpl +23 -0
  21. keystone_cli-0.1.26/src/keystone_cli/templates/kb-builder/agent.yaml.tmpl +45 -0
  22. keystone_cli-0.1.26/src/keystone_cli/templates/kb-builder/pkg/graph.py.tmpl +73 -0
  23. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/src/keystone_cli/templates/llm/agent.yaml.tmpl +8 -0
  24. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/src/keystone_cli/templates/llm/pkg/graph.py.tmpl +5 -9
  25. keystone_cli-0.1.26/src/keystone_cli/templates/llm-streaming/README.md.tmpl +46 -0
  26. keystone_cli-0.1.26/src/keystone_cli/templates/llm-streaming/agent.yaml.tmpl +39 -0
  27. keystone_cli-0.1.26/src/keystone_cli/templates/llm-streaming/pkg/graph.py.tmpl +53 -0
  28. keystone_cli-0.1.26/src/keystone_cli/templates/llm-streaming/pyproject.toml.tmpl +13 -0
  29. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/src/keystone_cli/templates/memory/agent.yaml.tmpl +3 -0
  30. keystone_cli-0.1.26/src/keystone_cli/templates/memory/env.tmpl +10 -0
  31. keystone_cli-0.1.26/src/keystone_cli/templates/memory/gitignore.tmpl +7 -0
  32. keystone_cli-0.1.26/src/keystone_cli/templates/memory/pkg/__init__.py.tmpl +0 -0
  33. keystone_cli-0.1.26/src/keystone_cli/templates/memory/pyproject.toml.tmpl +12 -0
  34. keystone_cli-0.1.26/src/keystone_cli/templates/memory-service/README.md.tmpl +70 -0
  35. keystone_cli-0.1.26/src/keystone_cli/templates/memory-service/agent.yaml.tmpl +36 -0
  36. keystone_cli-0.1.26/src/keystone_cli/templates/memory-service/env.tmpl +10 -0
  37. keystone_cli-0.1.26/src/keystone_cli/templates/memory-service/gitignore.tmpl +7 -0
  38. keystone_cli-0.1.26/src/keystone_cli/templates/memory-service/pkg/__init__.py.tmpl +0 -0
  39. keystone_cli-0.1.26/src/keystone_cli/templates/memory-service/pkg/graph.py.tmpl +98 -0
  40. keystone_cli-0.1.26/src/keystone_cli/templates/memory-service/pyproject.toml.tmpl +12 -0
  41. keystone_cli-0.1.26/src/keystone_cli/templates/rag-qa/agent.yaml.tmpl +61 -0
  42. keystone_cli-0.1.26/src/keystone_cli/templates/rag-qa/env.tmpl +10 -0
  43. keystone_cli-0.1.26/src/keystone_cli/templates/rag-qa/gitignore.tmpl +7 -0
  44. keystone_cli-0.1.26/src/keystone_cli/templates/rag-qa/pkg/__init__.py.tmpl +0 -0
  45. keystone_cli-0.1.26/src/keystone_cli/templates/rag-qa/pkg/graph.py.tmpl +73 -0
  46. keystone_cli-0.1.26/src/keystone_cli/templates/rag-qa/pyproject.toml.tmpl +12 -0
  47. keystone_cli-0.1.26/src/keystone_cli/templates/skill/README.md.tmpl +52 -0
  48. keystone_cli-0.1.26/src/keystone_cli/templates/skill/agent.yaml.tmpl +30 -0
  49. keystone_cli-0.1.26/src/keystone_cli/templates/skill/env.tmpl +10 -0
  50. keystone_cli-0.1.26/src/keystone_cli/templates/skill/gitignore.tmpl +7 -0
  51. keystone_cli-0.1.26/src/keystone_cli/templates/skill/pkg/__init__.py.tmpl +0 -0
  52. keystone_cli-0.1.26/src/keystone_cli/templates/skill/pkg/graph.py.tmpl +82 -0
  53. keystone_cli-0.1.26/src/keystone_cli/templates/skill/pyproject.toml.tmpl +12 -0
  54. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/src/keystone_cli/templates/tool-agent/agent.yaml.tmpl +8 -0
  55. keystone_cli-0.1.26/src/keystone_cli/templates/tool-agent/env.tmpl +10 -0
  56. keystone_cli-0.1.26/src/keystone_cli/templates/tool-agent/gitignore.tmpl +7 -0
  57. keystone_cli-0.1.26/src/keystone_cli/templates/tool-agent/pkg/__init__.py.tmpl +0 -0
  58. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/src/keystone_cli/templates/tool-agent/pkg/graph.py.tmpl +24 -16
  59. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/src/keystone_cli/templates/tool-agent/pyproject.toml.tmpl +3 -1
  60. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/src/keystone_cli.egg-info/PKG-INFO +2 -2
  61. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/src/keystone_cli.egg-info/SOURCES.txt +43 -0
  62. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/src/keystone_cli.egg-info/requires.txt +1 -1
  63. keystone_cli-0.1.26/tests/test_assistant_provenance_and_flags.py +180 -0
  64. keystone_cli-0.1.26/tests/test_auth.py +238 -0
  65. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/tests/test_deploy.py +78 -1
  66. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/tests/test_dev.py +1 -1
  67. keystone_cli-0.1.26/tests/test_doctor.py +195 -0
  68. keystone_cli-0.1.26/tests/test_encoding.py +131 -0
  69. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/tests/test_init.py +65 -1
  70. keystone_cli-0.1.26/tests/test_offline.py +348 -0
  71. keystone_cli-0.1.26/tests/test_stdio.py +90 -0
  72. keystone_cli-0.1.26/tests/test_tool_credentials.py +94 -0
  73. keystone_cli-0.1.10/src/keystone_cli/runner.py +0 -87
  74. keystone_cli-0.1.10/src/keystone_cli/templates/rag-qa/agent.yaml.tmpl +0 -34
  75. keystone_cli-0.1.10/src/keystone_cli/templates/rag-qa/pkg/graph.py.tmpl +0 -69
  76. keystone_cli-0.1.10/tests/test_auth.py +0 -118
  77. keystone_cli-0.1.10/tests/test_offline.py +0 -34
  78. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/setup.cfg +0 -0
  79. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/src/keystone_cli/__init__.py +0 -0
  80. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/src/keystone_cli/_build_env.py +0 -0
  81. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/src/keystone_cli/_version.py +0 -0
  82. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/src/keystone_cli/auth/__init__.py +0 -0
  83. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/src/keystone_cli/auth/device_flow.py +0 -0
  84. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/src/keystone_cli/commands/__init__.py +0 -0
  85. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/src/keystone_cli/commands/login.py +0 -0
  86. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/src/keystone_cli/commands/workspace.py +0 -0
  87. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/src/keystone_cli/platform_client.py +0 -0
  88. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/src/keystone_cli/templates/blank/README.md.tmpl +0 -0
  89. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/src/keystone_cli/templates/blank/env.tmpl +0 -0
  90. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/src/keystone_cli/templates/blank/gitignore.tmpl +0 -0
  91. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/src/keystone_cli/templates/blank/pkg/__init__.py.tmpl +0 -0
  92. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/src/keystone_cli/templates/blank/pkg/graph.py.tmpl +0 -0
  93. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/src/keystone_cli/templates/blank/pyproject.toml.tmpl +0 -0
  94. {keystone_cli-0.1.10/src/keystone_cli/templates/hitl → keystone_cli-0.1.26/src/keystone_cli/templates/guardrails}/env.tmpl +0 -0
  95. {keystone_cli-0.1.10/src/keystone_cli/templates/hitl → keystone_cli-0.1.26/src/keystone_cli/templates/guardrails}/gitignore.tmpl +0 -0
  96. {keystone_cli-0.1.10/src/keystone_cli/templates/hitl → keystone_cli-0.1.26/src/keystone_cli/templates/guardrails}/pkg/__init__.py.tmpl +0 -0
  97. {keystone_cli-0.1.10/src/keystone_cli/templates/hitl → keystone_cli-0.1.26/src/keystone_cli/templates/guardrails}/pyproject.toml.tmpl +0 -0
  98. {keystone_cli-0.1.10/src/keystone_cli/templates/llm → keystone_cli-0.1.26/src/keystone_cli/templates/hitl}/env.tmpl +0 -0
  99. {keystone_cli-0.1.10/src/keystone_cli/templates/llm → keystone_cli-0.1.26/src/keystone_cli/templates/hitl}/gitignore.tmpl +0 -0
  100. {keystone_cli-0.1.10/src/keystone_cli/templates/llm → keystone_cli-0.1.26/src/keystone_cli/templates/hitl}/pkg/__init__.py.tmpl +0 -0
  101. {keystone_cli-0.1.10/src/keystone_cli/templates/llm → keystone_cli-0.1.26/src/keystone_cli/templates/hitl}/pyproject.toml.tmpl +0 -0
  102. {keystone_cli-0.1.10/src/keystone_cli/templates/memory → keystone_cli-0.1.26/src/keystone_cli/templates/kb-builder}/env.tmpl +0 -0
  103. {keystone_cli-0.1.10/src/keystone_cli/templates/memory → keystone_cli-0.1.26/src/keystone_cli/templates/kb-builder}/gitignore.tmpl +0 -0
  104. {keystone_cli-0.1.10/src/keystone_cli/templates/memory → keystone_cli-0.1.26/src/keystone_cli/templates/kb-builder}/pkg/__init__.py.tmpl +0 -0
  105. {keystone_cli-0.1.10/src/keystone_cli/templates/memory → keystone_cli-0.1.26/src/keystone_cli/templates/kb-builder}/pyproject.toml.tmpl +0 -0
  106. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/src/keystone_cli/templates/llm/README.md.tmpl +0 -0
  107. {keystone_cli-0.1.10/src/keystone_cli/templates/tool-agent → keystone_cli-0.1.26/src/keystone_cli/templates/llm}/env.tmpl +0 -0
  108. {keystone_cli-0.1.10/src/keystone_cli/templates/tool-agent → keystone_cli-0.1.26/src/keystone_cli/templates/llm}/gitignore.tmpl +0 -0
  109. {keystone_cli-0.1.10/src/keystone_cli/templates/tool-agent → keystone_cli-0.1.26/src/keystone_cli/templates/llm}/pkg/__init__.py.tmpl +0 -0
  110. {keystone_cli-0.1.10/src/keystone_cli/templates/rag-qa → keystone_cli-0.1.26/src/keystone_cli/templates/llm}/pyproject.toml.tmpl +0 -0
  111. {keystone_cli-0.1.10/src/keystone_cli/templates/rag-qa → keystone_cli-0.1.26/src/keystone_cli/templates/llm-streaming}/env.tmpl +0 -0
  112. {keystone_cli-0.1.10/src/keystone_cli/templates/rag-qa → keystone_cli-0.1.26/src/keystone_cli/templates/llm-streaming}/gitignore.tmpl +0 -0
  113. {keystone_cli-0.1.10/src/keystone_cli/templates/rag-qa → keystone_cli-0.1.26/src/keystone_cli/templates/llm-streaming}/pkg/__init__.py.tmpl +0 -0
  114. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/src/keystone_cli/templates/memory/README.md.tmpl +0 -0
  115. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/src/keystone_cli/templates/memory/pkg/graph.py.tmpl +0 -0
  116. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/src/keystone_cli/templates/rag-qa/README.md.tmpl +0 -0
  117. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/src/keystone_cli/templates/tool-agent/README.md.tmpl +0 -0
  118. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/src/keystone_cli.egg-info/dependency_links.txt +0 -0
  119. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/src/keystone_cli.egg-info/entry_points.txt +0 -0
  120. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/src/keystone_cli.egg-info/top_level.txt +0 -0
  121. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/tests/test_agent.py +0 -0
  122. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/tests/test_build_progress.py +0 -0
  123. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/tests/test_ci_token.py +0 -0
  124. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/tests/test_invoke.py +0 -0
  125. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/tests/test_login_command.py +0 -0
  126. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/tests/test_platform_client.py +0 -0
  127. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/tests/test_publish.py +0 -0
  128. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/tests/test_retire_delete.py +0 -0
  129. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/tests/test_runs.py +0 -0
  130. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/tests/test_status.py +0 -0
  131. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/tests/test_threads.py +0 -0
  132. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/tests/test_traces.py +0 -0
  133. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/tests/test_version.py +0 -0
  134. {keystone_cli-0.1.10 → keystone_cli-0.1.26}/tests/test_workspace.py +0 -0
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: keystone-cli
3
- Version: 0.1.10
3
+ Version: 0.1.26
4
4
  Summary: Keystone pro-code agent CLI — author, validate, and run agents locally, then deploy and invoke them on the Keystone platform.
5
5
  Requires-Python: >=3.12
6
- Requires-Dist: keystone-agent-sdk<0.4,>=0.3
6
+ Requires-Dist: keystone-agent-sdk<0.11,>=0.10
7
7
  Requires-Dist: keystone-request-context<0.4,>=0.3
8
8
  Requires-Dist: typer<1,>=0.12
9
9
  Requires-Dist: httpx<1,>=0.27
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "keystone-cli"
7
- version = "0.1.10"
7
+ version = "0.1.26"
8
8
  description = "Keystone pro-code agent CLI — author, validate, and run agents locally, then deploy and invoke them on the Keystone platform."
9
9
  requires-python = ">=3.12"
10
10
  dependencies = [
11
11
  # Pinned to a compatible range: on an index (Artifact Registry) an unpinned name resolves to
12
12
  # ANY published version — the wheel's metadata is the only pin that travels with it.
13
- "keystone-agent-sdk>=0.3,<0.4",
13
+ "keystone-agent-sdk>=0.10,<0.11",
14
14
  # Transitive (the SDK's building-block clients) + used directly by `run` to set a local
15
15
  # caller-identity ContextVar and to patch the clients for --offline. uv sources are NOT
16
16
  # transitive, so declare it directly (cf. services/llm-gateway).
@@ -45,7 +45,7 @@ where = ["src"]
45
45
  keystone_cli = ["templates/*/*.tmpl", "templates/*/pkg/*.tmpl"]
46
46
 
47
47
  [tool.ruff]
48
- target-version = "0.1.10"
48
+ target-version = "0.1.26"
49
49
  line-length = 120
50
50
 
51
51
  [tool.ruff.lint]
@@ -4,12 +4,20 @@ from __future__ import annotations
4
4
 
5
5
  import typer
6
6
 
7
+ from keystone_cli._stdio import make_stdio_unicode_safe
7
8
  from keystone_cli._version import version_line
8
9
  from keystone_cli.commands.agent import agent_app
9
10
  from keystone_cli.commands.dev import dev
11
+ from keystone_cli.commands.kb import kb_app
10
12
  from keystone_cli.commands.login import login
13
+ from keystone_cli.commands.tool_credentials import tool_credentials_app
11
14
  from keystone_cli.commands.workspace import workspace_app
12
15
 
16
+ # FDP-4360 — before Typer builds anything. `--help` is rendered inside `app()`, and the help
17
+ # text comes from docstrings carrying `→` and `─`, so a hook that ran per-command would be too
18
+ # late for the very invocation QC reported first. No-op on a console that already copes.
19
+ make_stdio_unicode_safe()
20
+
13
21
  app = typer.Typer(
14
22
  help="Keystone pro-code agent CLI — author, validate, and run agents locally.",
15
23
  no_args_is_help=True,
@@ -45,6 +53,8 @@ def version() -> None:
45
53
 
46
54
  app.add_typer(agent_app, name="agent")
47
55
  app.add_typer(workspace_app, name="workspace")
56
+ app.add_typer(kb_app, name="kb")
57
+ app.add_typer(tool_credentials_app, name="tool-credentials")
48
58
  app.command(name="login")(login)
49
59
  app.command(name="dev")(dev)
50
60
 
@@ -0,0 +1,68 @@
1
+ """Make stdout/stderr able to carry the CLI's own glyphs (FDP-4360).
2
+
3
+ Every command prints at least one of ``✓ ✗ → ─ ⚠ ↻``, and Typer renders ``--help`` from
4
+ docstrings that contain them too. On a console whose encoding cannot represent those — a
5
+ Windows cp1252 shell is the reported case, but any ``LANG=C`` box behaves the same — writing
6
+ them raises ``UnicodeEncodeError`` and the command dies at the moment it reports success.
7
+ QC hit exactly that: ``keystone agent init`` created the project and then crashed printing
8
+ ``✓``, so the user saw only a traceback and had no way to tell the work had been done.
9
+
10
+ The fix is deliberately conditional rather than unconditional. A stream that can already
11
+ carry the glyphs is left alone — that keeps a deliberate ``PYTHONIOENCODING`` honoured, and
12
+ keeps this from being one more global that reaches into a process it does not own.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import sys
18
+ from typing import IO, Any
19
+
20
+ #: The characters the CLI actually emits that cp1252 (and most 8-bit encodings) cannot carry.
21
+ #: Taken from the source, not guessed: ``— … • · §`` ARE representable in cp1252 and so would
22
+ #: hide the problem if the probe included only them.
23
+ _PROBE = "→✓✗─⚠↻"
24
+
25
+
26
+ def _can_carry_glyphs(stream: IO[Any]) -> bool:
27
+ encoding = getattr(stream, "encoding", None)
28
+ if not encoding:
29
+ # No declared encoding (a pipe wrapper, a test double). Nothing to prove it cannot
30
+ # cope, and nothing to reconfigure either — treat it as fine and leave it be.
31
+ return True
32
+ try:
33
+ _PROBE.encode(encoding)
34
+ except (UnicodeEncodeError, LookupError):
35
+ return False
36
+ return True
37
+
38
+
39
+ def make_stdio_unicode_safe(streams: tuple[IO[Any], ...] | None = None) -> None:
40
+ """Re-open stdout/stderr as UTF-8 when their current encoding cannot carry our glyphs.
41
+
42
+ Called once from the CLI entry point, before Typer builds anything — ``--help`` is
43
+ rendered during ``app()``, so a later hook would already be too late.
44
+
45
+ Three things are deliberately tolerated rather than raised on, because a CLI that cannot
46
+ fix its console must still run the command the user asked for:
47
+
48
+ * a stream with no ``reconfigure`` (``io.TextIOWrapper`` has it; pytest's capture object
49
+ and anything hand-rolled may not),
50
+ * ``reconfigure`` refusing — a detached or closed stream,
51
+ * UTF-8 itself being rejected, in which case the encoding is left as it is and only the
52
+ error handler is relaxed, so an unencodable glyph degrades to ``?`` instead of a crash.
53
+ """
54
+ for stream in streams if streams is not None else (sys.stdout, sys.stderr):
55
+ if _can_carry_glyphs(stream):
56
+ continue
57
+ reconfigure = getattr(stream, "reconfigure", None)
58
+ if reconfigure is None:
59
+ continue
60
+ try:
61
+ reconfigure(encoding="utf-8", errors="replace")
62
+ except (ValueError, OSError, LookupError):
63
+ # Last resort: keep the console's own encoding and stop it from raising. The
64
+ # glyphs become '?', which is a cosmetic loss; the crash was a functional one.
65
+ try:
66
+ reconfigure(errors="replace")
67
+ except (ValueError, OSError):
68
+ pass
@@ -95,14 +95,62 @@ def load() -> AuthConfig:
95
95
  path = config_path()
96
96
  if not path.exists():
97
97
  return AuthConfig()
98
- data = json.loads(path.read_text())
98
+ # FDP-4716: `save()` below writes this file with encoding="utf-8"; reading it back with the
99
+ # locale encoding made the two disagree on Windows, where a workspace name with diacritics
100
+ # turned a valid session into "not logged in".
101
+ data = json.loads(path.read_text(encoding="utf-8"))
99
102
  known = {f.name for f in fields(AuthConfig)}
100
103
  return AuthConfig(**{k: v for k, v in data.items() if k in known})
101
104
 
102
105
 
106
+ def _ensure_private_dir(directory: Path) -> None:
107
+ """Config dir, owner-only. ``mode=`` is IGNORED when the directory already exists, so the chmod
108
+ is not redundant — it is the only thing that narrows a ``~/.keystone`` an older CLI left at 0755.
109
+
110
+ Best-effort on purpose: on a shared runner the directory can belong to someone else, and failing
111
+ the whole ``login`` over a directory bit would be worse than the bit. The FILE is the credential
112
+ and its mode is enforced unconditionally below; this is the layer around it.
113
+ """
114
+ directory.mkdir(parents=True, exist_ok=True, mode=0o700)
115
+ try:
116
+ os.chmod(directory, 0o700)
117
+ except OSError:
118
+ pass
119
+
120
+
103
121
  def save(cfg: AuthConfig) -> None:
122
+ """Persist the config, with the credential never once readable by anyone else (FDP-4574).
123
+
124
+ This used to be ``write_text()`` then ``chmod(0o600)``. The end state was right, but between the
125
+ two calls the refresh token sat on disk at whatever the umask allowed — 0644 on a default box —
126
+ so on a shared host or CI runner any local user could read it and mint access tokens as the
127
+ developer until it was revoked. Applying permissions AFTER the content is the bug; the window is
128
+ the vulnerability, not the final mode, which is why the existing 0600 assertion never saw it.
129
+
130
+ So the bytes only ever land in a file that was created 0600. ``O_EXCL`` (after clearing a stale
131
+ temp) guarantees WE created it, so ``O_CREAT``'s mode really applies rather than being inherited
132
+ from a leftover file — the one way this shape can silently degrade. umask can only remove bits,
133
+ never add them, so a permissive umask cannot widen it.
134
+
135
+ ``os.replace`` then swaps the inode in atomically. Two things fall out for free: a crash can no
136
+ longer leave a half-written config that every later command fails to parse, and an existing
137
+ world-readable ``config.json`` from an older CLI is REPLACED rather than rewritten in place, so
138
+ it is fixed on the next save instead of keeping its old mode forever.
139
+
140
+ The temp carries the pid so two CLI processes saving at once (a `dev` watcher plus a command in
141
+ another terminal) cannot collide on ``O_EXCL``, and it is removed in a ``finally`` so a failed
142
+ save does not leave a token sitting in an orphan file.
143
+ """
104
144
  path = config_path()
105
- path.parent.mkdir(parents=True, exist_ok=True)
106
- # Write then lock to 0600 (owner-only) — the refresh token is a credential.
107
- path.write_text(json.dumps(asdict(cfg), indent=2))
108
- path.chmod(0o600)
145
+ _ensure_private_dir(path.parent)
146
+ tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp")
147
+ tmp.unlink(missing_ok=True)
148
+ # O_BINARY (Windows only, 0 elsewhere) keeps newline handling in the text wrapper alone, the way
149
+ # open() does it — without it the CRT would translate as well, on top of the wrapper.
150
+ flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY | getattr(os, "O_BINARY", 0)
151
+ try:
152
+ with os.fdopen(os.open(tmp, flags, 0o600), "w", encoding="utf-8") as fh:
153
+ fh.write(json.dumps(asdict(cfg), indent=2))
154
+ os.replace(tmp, path)
155
+ finally:
156
+ tmp.unlink(missing_ok=True)