keystone-cli 0.1.27__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/PKG-INFO +3 -3
  2. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/pyproject.toml +6 -6
  3. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/__main__.py +10 -2
  4. keystone_cli-0.2.0/src/keystone_cli/agent_logs.py +463 -0
  5. keystone_cli-0.2.0/src/keystone_cli/agent_skills.py +36 -0
  6. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/commands/agent.py +1209 -122
  7. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/commands/dev.py +6 -1
  8. keystone_cli-0.2.0/src/keystone_cli/commands/kb.py +286 -0
  9. keystone_cli-0.2.0/src/keystone_cli/commands/kb_metadata.py +392 -0
  10. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/commands/login.py +17 -3
  11. keystone_cli-0.2.0/src/keystone_cli/commands/skills.py +40 -0
  12. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/commands/tool_credentials.py +67 -13
  13. keystone_cli-0.2.0/src/keystone_cli/commands/whoami.py +41 -0
  14. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/commands/workspace.py +21 -4
  15. keystone_cli-0.2.0/src/keystone_cli/run_debug.py +123 -0
  16. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/runner.py +131 -4
  17. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/scaffold.py +6 -0
  18. keystone_cli-0.2.0/src/keystone_cli/skills/keystone-debug/SKILL.md +65 -0
  19. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/blank/README.md.tmpl +3 -1
  20. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/hitl/README.md.tmpl +35 -0
  21. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/hitl/agent.yaml.tmpl +2 -0
  22. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/hitl/pkg/graph.py.tmpl +3 -0
  23. keystone_cli-0.2.0/src/keystone_cli/templates/kb-builder/README.md.tmpl +53 -0
  24. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/kb-builder/agent.yaml.tmpl +21 -4
  25. keystone_cli-0.2.0/src/keystone_cli/templates/kb-builder/pkg/graph.py.tmpl +139 -0
  26. keystone_cli-0.2.0/src/keystone_cli/templates/kb-builder/pyproject.toml.tmpl +12 -0
  27. keystone_cli-0.2.0/src/keystone_cli/templates/llm-streaming/README.md.tmpl +77 -0
  28. keystone_cli-0.2.0/src/keystone_cli/templates/ocr/README.md.tmpl +57 -0
  29. keystone_cli-0.2.0/src/keystone_cli/templates/ocr/agent.yaml.tmpl +49 -0
  30. keystone_cli-0.2.0/src/keystone_cli/templates/ocr/pkg/graph.py.tmpl +112 -0
  31. keystone_cli-0.2.0/src/keystone_cli/templates/ocr/pyproject.toml.tmpl +12 -0
  32. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/rag-qa/agent.yaml.tmpl +3 -0
  33. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/rag-qa/pkg/graph.py.tmpl +14 -1
  34. keystone_cli-0.2.0/src/keystone_cli/templates/rag-qa/pyproject.toml.tmpl +12 -0
  35. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/tool-agent/README.md.tmpl +2 -1
  36. keystone_cli-0.2.0/src/keystone_cli/templates/tool-agent/env.tmpl +10 -0
  37. keystone_cli-0.2.0/src/keystone_cli/templates/tool-agent/gitignore.tmpl +7 -0
  38. keystone_cli-0.2.0/src/keystone_cli/templates/tool-agent/pkg/__init__.py.tmpl +0 -0
  39. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli.egg-info/PKG-INFO +3 -3
  40. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli.egg-info/SOURCES.txt +23 -0
  41. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli.egg-info/requires.txt +1 -1
  42. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/tests/test_deploy.py +73 -1
  43. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/tests/test_doctor.py +65 -2
  44. keystone_cli-0.2.0/tests/test_help_quality.py +103 -0
  45. keystone_cli-0.2.0/tests/test_hitl_approver_cli.py +129 -0
  46. keystone_cli-0.2.0/tests/test_hitl_decisions_cli.py +139 -0
  47. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/tests/test_init.py +104 -0
  48. keystone_cli-0.2.0/tests/test_invoke_stream.py +206 -0
  49. keystone_cli-0.2.0/tests/test_kb_commands.py +167 -0
  50. keystone_cli-0.2.0/tests/test_kb_metadata_commands.py +311 -0
  51. keystone_cli-0.2.0/tests/test_logs.py +538 -0
  52. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/tests/test_offline.py +58 -0
  53. keystone_cli-0.2.0/tests/test_run_debug.py +198 -0
  54. keystone_cli-0.2.0/tests/test_skills.py +83 -0
  55. keystone_cli-0.1.27/src/keystone_cli/commands/kb.py +0 -144
  56. keystone_cli-0.1.27/src/keystone_cli/templates/kb-builder/README.md.tmpl +0 -23
  57. keystone_cli-0.1.27/src/keystone_cli/templates/kb-builder/pkg/graph.py.tmpl +0 -73
  58. keystone_cli-0.1.27/src/keystone_cli/templates/llm-streaming/README.md.tmpl +0 -46
  59. keystone_cli-0.1.27/src/keystone_cli/templates/rag-qa/pyproject.toml.tmpl +0 -12
  60. keystone_cli-0.1.27/src/keystone_cli/templates/skill/pyproject.toml.tmpl +0 -12
  61. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/setup.cfg +0 -0
  62. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/__init__.py +0 -0
  63. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/_build_env.py +0 -0
  64. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/_stdio.py +0 -0
  65. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/_version.py +0 -0
  66. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/auth/__init__.py +0 -0
  67. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/auth/device_flow.py +0 -0
  68. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/auth/token_store.py +0 -0
  69. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/commands/__init__.py +0 -0
  70. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/devloop.py +0 -0
  71. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/platform_client.py +0 -0
  72. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/blank/agent.yaml.tmpl +0 -0
  73. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/blank/env.tmpl +0 -0
  74. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/blank/gitignore.tmpl +0 -0
  75. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/blank/pkg/__init__.py.tmpl +0 -0
  76. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/blank/pkg/graph.py.tmpl +0 -0
  77. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/blank/pyproject.toml.tmpl +0 -0
  78. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/guardrails/README.md.tmpl +0 -0
  79. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/guardrails/agent.yaml.tmpl +0 -0
  80. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/guardrails/env.tmpl +0 -0
  81. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/guardrails/gitignore.tmpl +0 -0
  82. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/guardrails/pkg/__init__.py.tmpl +0 -0
  83. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/guardrails/pkg/graph.py.tmpl +0 -0
  84. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/guardrails/pyproject.toml.tmpl +0 -0
  85. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/hitl/env.tmpl +0 -0
  86. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/hitl/gitignore.tmpl +0 -0
  87. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/hitl/pkg/__init__.py.tmpl +0 -0
  88. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/hitl/pyproject.toml.tmpl +0 -0
  89. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/kb-builder/env.tmpl +0 -0
  90. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/kb-builder/gitignore.tmpl +0 -0
  91. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/kb-builder/pkg/__init__.py.tmpl +0 -0
  92. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/llm/README.md.tmpl +0 -0
  93. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/llm/agent.yaml.tmpl +0 -0
  94. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/llm/env.tmpl +0 -0
  95. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/llm/gitignore.tmpl +0 -0
  96. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/llm/pkg/__init__.py.tmpl +0 -0
  97. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/llm/pkg/graph.py.tmpl +0 -0
  98. {keystone_cli-0.1.27/src/keystone_cli/templates/kb-builder → keystone_cli-0.2.0/src/keystone_cli/templates/llm}/pyproject.toml.tmpl +0 -0
  99. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/llm-streaming/agent.yaml.tmpl +0 -0
  100. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/llm-streaming/env.tmpl +0 -0
  101. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/llm-streaming/gitignore.tmpl +0 -0
  102. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/llm-streaming/pkg/__init__.py.tmpl +0 -0
  103. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/llm-streaming/pkg/graph.py.tmpl +0 -0
  104. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/llm-streaming/pyproject.toml.tmpl +0 -0
  105. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/memory/README.md.tmpl +0 -0
  106. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/memory/agent.yaml.tmpl +0 -0
  107. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/memory/env.tmpl +0 -0
  108. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/memory/gitignore.tmpl +0 -0
  109. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/memory/pkg/__init__.py.tmpl +0 -0
  110. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/memory/pkg/graph.py.tmpl +0 -0
  111. {keystone_cli-0.1.27/src/keystone_cli/templates/memory-service → keystone_cli-0.2.0/src/keystone_cli/templates/memory}/pyproject.toml.tmpl +0 -0
  112. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/memory-service/README.md.tmpl +0 -0
  113. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/memory-service/agent.yaml.tmpl +0 -0
  114. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/memory-service/env.tmpl +0 -0
  115. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/memory-service/gitignore.tmpl +0 -0
  116. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/memory-service/pkg/__init__.py.tmpl +0 -0
  117. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/memory-service/pkg/graph.py.tmpl +0 -0
  118. {keystone_cli-0.1.27/src/keystone_cli/templates/llm → keystone_cli-0.2.0/src/keystone_cli/templates/memory-service}/pyproject.toml.tmpl +0 -0
  119. {keystone_cli-0.1.27/src/keystone_cli/templates/rag-qa → keystone_cli-0.2.0/src/keystone_cli/templates/ocr}/env.tmpl +0 -0
  120. {keystone_cli-0.1.27/src/keystone_cli/templates/rag-qa → keystone_cli-0.2.0/src/keystone_cli/templates/ocr}/gitignore.tmpl +0 -0
  121. {keystone_cli-0.1.27/src/keystone_cli/templates/rag-qa → keystone_cli-0.2.0/src/keystone_cli/templates/ocr}/pkg/__init__.py.tmpl +0 -0
  122. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/rag-qa/README.md.tmpl +0 -0
  123. {keystone_cli-0.1.27/src/keystone_cli/templates/skill → keystone_cli-0.2.0/src/keystone_cli/templates/rag-qa}/env.tmpl +0 -0
  124. {keystone_cli-0.1.27/src/keystone_cli/templates/skill → keystone_cli-0.2.0/src/keystone_cli/templates/rag-qa}/gitignore.tmpl +0 -0
  125. {keystone_cli-0.1.27/src/keystone_cli/templates/skill → keystone_cli-0.2.0/src/keystone_cli/templates/rag-qa}/pkg/__init__.py.tmpl +0 -0
  126. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/skill/README.md.tmpl +0 -0
  127. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/skill/agent.yaml.tmpl +0 -0
  128. {keystone_cli-0.1.27/src/keystone_cli/templates/tool-agent → keystone_cli-0.2.0/src/keystone_cli/templates/skill}/env.tmpl +0 -0
  129. {keystone_cli-0.1.27/src/keystone_cli/templates/tool-agent → keystone_cli-0.2.0/src/keystone_cli/templates/skill}/gitignore.tmpl +0 -0
  130. {keystone_cli-0.1.27/src/keystone_cli/templates/tool-agent → keystone_cli-0.2.0/src/keystone_cli/templates/skill}/pkg/__init__.py.tmpl +0 -0
  131. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/skill/pkg/graph.py.tmpl +0 -0
  132. {keystone_cli-0.1.27/src/keystone_cli/templates/memory → keystone_cli-0.2.0/src/keystone_cli/templates/skill}/pyproject.toml.tmpl +0 -0
  133. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/tool-agent/agent.yaml.tmpl +0 -0
  134. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/tool-agent/pkg/graph.py.tmpl +0 -0
  135. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/tool-agent/pyproject.toml.tmpl +0 -0
  136. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli.egg-info/dependency_links.txt +0 -0
  137. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli.egg-info/entry_points.txt +0 -0
  138. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli.egg-info/top_level.txt +0 -0
  139. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/tests/test_agent.py +0 -0
  140. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/tests/test_assistant_provenance_and_flags.py +0 -0
  141. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/tests/test_auth.py +0 -0
  142. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/tests/test_build_progress.py +0 -0
  143. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/tests/test_ci_token.py +0 -0
  144. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/tests/test_config_env_mismatch.py +0 -0
  145. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/tests/test_dev.py +0 -0
  146. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/tests/test_encoding.py +0 -0
  147. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/tests/test_invoke.py +0 -0
  148. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/tests/test_login_command.py +0 -0
  149. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/tests/test_platform_client.py +0 -0
  150. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/tests/test_publish.py +0 -0
  151. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/tests/test_retire_delete.py +0 -0
  152. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/tests/test_runs.py +0 -0
  153. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/tests/test_status.py +0 -0
  154. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/tests/test_stdio.py +0 -0
  155. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/tests/test_threads.py +0 -0
  156. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/tests/test_tool_credentials.py +0 -0
  157. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/tests/test_traces.py +0 -0
  158. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/tests/test_version.py +0 -0
  159. {keystone_cli-0.1.27 → keystone_cli-0.2.0}/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.27
4
- Summary: Keystone pro-code agent CLI — author, validate, and run agents locally, then deploy and invoke them on the Keystone platform.
3
+ Version: 0.2.0
4
+ Summary: Keystone pro-code agent CLI — build, test, deploy and operate agents on the Keystone platform.
5
5
  Requires-Python: >=3.12
6
- Requires-Dist: keystone-agent-sdk<0.11,>=0.10
6
+ Requires-Dist: keystone-agent-sdk<0.14,>=0.13.3
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.27"
8
- description = "Keystone pro-code agent CLI — author, validate, and run agents locally, then deploy and invoke them on the Keystone platform."
7
+ version = "0.2.0"
8
+ description = "Keystone pro-code agent CLI — build, test, deploy and operate agents 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.10,<0.11",
13
+ "keystone-agent-sdk>=0.13.3,<0.14",
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).
@@ -42,10 +42,10 @@ where = ["src"]
42
42
 
43
43
  # `agent init` templates ride INSIDE the wheel (offline scaffold — no network fetch).
44
44
  [tool.setuptools.package-data]
45
- keystone_cli = ["templates/*/*.tmpl", "templates/*/pkg/*.tmpl"]
45
+ keystone_cli = ["templates/*/*.tmpl", "templates/*/pkg/*.tmpl", "skills/*/SKILL.md"]
46
46
 
47
47
  [tool.ruff]
48
- target-version = "0.1.27"
48
+ target-version = "0.2.0"
49
49
  line-length = 120
50
50
 
51
51
  [tool.ruff.lint]
@@ -53,4 +53,4 @@ extend-select = ["E", "F", "I", "UP", "B", "A"]
53
53
 
54
54
  [tool.pytest.ini_options]
55
55
  testpaths = ["tests"]
56
- addopts = "-ra --strict-markers --cov=keystone_cli --cov-fail-under=80"
56
+ addopts = "-ra --strict-markers --cov=keystone_cli --cov-fail-under=85"
@@ -10,7 +10,9 @@ from keystone_cli.commands.agent import agent_app
10
10
  from keystone_cli.commands.dev import dev
11
11
  from keystone_cli.commands.kb import kb_app
12
12
  from keystone_cli.commands.login import login
13
+ from keystone_cli.commands.skills import skills_app
13
14
  from keystone_cli.commands.tool_credentials import tool_credentials_app
15
+ from keystone_cli.commands.whoami import whoami
14
16
  from keystone_cli.commands.workspace import workspace_app
15
17
 
16
18
  # FDP-4360 — before Typer builds anything. `--help` is rendered inside `app()`, and the help
@@ -19,7 +21,7 @@ from keystone_cli.commands.workspace import workspace_app
19
21
  make_stdio_unicode_safe()
20
22
 
21
23
  app = typer.Typer(
22
- help="Keystone pro-code agent CLI — author, validate, and run agents locally.",
24
+ help="Keystone CLI — build, test, deploy and operate pro-code agents.",
23
25
  no_args_is_help=True,
24
26
  add_completion=False,
25
27
  )
@@ -47,7 +49,11 @@ def _main(
47
49
 
48
50
  @app.command(name="version")
49
51
  def version() -> None:
50
- """Show the CLI version, its target env, and the bundled agent-sdk version."""
52
+ """Show the CLI version, its target env and the bundled agent-sdk version.
53
+
54
+ Example:
55
+ keystone version
56
+ """
51
57
  typer.echo(version_line())
52
58
 
53
59
 
@@ -55,8 +61,10 @@ app.add_typer(agent_app, name="agent")
55
61
  app.add_typer(workspace_app, name="workspace")
56
62
  app.add_typer(kb_app, name="kb")
57
63
  app.add_typer(tool_credentials_app, name="tool-credentials")
64
+ app.add_typer(skills_app, name="skills")
58
65
  app.command(name="login")(login)
59
66
  app.command(name="dev")(dev)
67
+ app.command(name="whoami")(whoami)
60
68
 
61
69
 
62
70
  if __name__ == "__main__":
@@ -0,0 +1,463 @@
1
+ """``keystone agent logs`` — the pure half: time windows, query params, rendering, paging, SSE.
2
+
3
+ The command in :mod:`keystone_cli.commands.agent` owns auth, agent-name resolution and printing;
4
+ everything here takes plain values or an ``httpx.Client`` so it is testable without a terminal.
5
+
6
+ Backend: the agent-logs service on the CONTROL edge (``/api/agent-logs/v1``):
7
+
8
+ * ``GET /agents/{agent_id}/logs`` — history, newest first, ``meta.next_before`` cursor, a window of
9
+ at most ``MAX_RANGE`` and ``limit`` ≤ 500 per page;
10
+ * ``GET /agents/{agent_id}/logs/stream`` — Server-Sent Events: ``line`` events (same shape), a
11
+ ``: keep-alive`` comment every ~15 s, ``end`` after ~15 min (reconnect with ``since`` = the last
12
+ line's timestamp), ``error`` when the viewer fell too far behind.
13
+
14
+ A line carries ``run_id`` and ``correlation_id`` (== a Langfuse trace id), which is what joins a
15
+ run, its trace and its logs.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import contextlib
21
+ import dataclasses
22
+ import json
23
+ import re
24
+ import socket
25
+ import threading
26
+ from collections.abc import Callable, Iterator
27
+ from dataclasses import dataclass, field
28
+ from datetime import UTC, datetime, timedelta
29
+ from typing import Any
30
+
31
+ import httpx
32
+
33
+ LOGS_PATH = "/api/agent-logs/v1/agents/{agent_id}/logs"
34
+ STREAM_PATH = "/api/agent-logs/v1/agents/{agent_id}/logs/stream"
35
+
36
+ #: The service's own vocabulary (structlog levels, lowest first); a level means "this and above".
37
+ LEVELS = ("debug", "info", "warning", "error", "critical")
38
+ #: The service refuses a wider window (``max_range_days``).
39
+ MAX_RANGE = timedelta(days=7)
40
+ #: Server page size cap.
41
+ MAX_PAGE = 500
42
+
43
+ _REL = re.compile(r"\A(\d+)([smhd])\Z")
44
+ _UNITS = {"s": "seconds", "m": "minutes", "h": "hours", "d": "days"}
45
+
46
+
47
+ class LogsError(Exception):
48
+ """A request the CLI can explain in one line (bad input, or a refusal from the service)."""
49
+
50
+
51
+ def parse_time(raw: str, *, now: datetime | None = None) -> datetime:
52
+ """``15m`` / ``2h`` / ``3d`` / ``30s`` (that long ago) or an ISO-8601 instant → aware UTC."""
53
+ now = now or datetime.now(UTC)
54
+ text = raw.strip()
55
+ if m := _REL.match(text):
56
+ return now - timedelta(**{_UNITS[m.group(2)]: int(m.group(1))})
57
+ try:
58
+ when = datetime.fromisoformat(text.replace("Z", "+00:00"))
59
+ except ValueError as exc:
60
+ raise LogsError(f"'{raw}' is not a time: use 15m, 2h, 3d or an ISO-8601 instant") from exc
61
+ return when if when.tzinfo else when.replace(tzinfo=UTC)
62
+
63
+
64
+ def iso(when: datetime) -> str:
65
+ return when.astimezone(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
66
+
67
+
68
+ def run_window(run: dict[str, Any], *, now: datetime | None = None) -> tuple[datetime, datetime]:
69
+ """The time window a run's logs fall in: a minute before it was created to five after it last
70
+ changed (log shipping lags a few seconds; a slow worker logs after the status flips).
71
+
72
+ Without it ``--run`` would search the default last 15 minutes and say "no logs" for any run
73
+ older than that — the most confusing answer it could give. A run read that carries no
74
+ timestamps (a background run's detail does not, today) gets the last 24 hours: the run filter
75
+ keeps that search narrow, and 15 minutes would miss anything older."""
76
+ now = now or datetime.now(UTC)
77
+ created = parse_stamp(run.get("created_at")) or now - timedelta(hours=24)
78
+ last = parse_stamp(run.get("updated_at")) or parse_stamp(run.get("completed_at")) or now
79
+ end = min(max(last, created) + timedelta(minutes=5), now)
80
+ # min(): a server clock ahead of this machine's must not yield an empty (inverted) window.
81
+ start = min(created, end) - timedelta(minutes=1)
82
+ if end - start > MAX_RANGE:
83
+ start = end - MAX_RANGE
84
+ return start, end
85
+
86
+
87
+ def parse_stamp(raw: Any) -> datetime | None:
88
+ if not isinstance(raw, str) or not raw:
89
+ return None
90
+ try:
91
+ when = datetime.fromisoformat(raw.replace("Z", "+00:00"))
92
+ except ValueError:
93
+ return None
94
+ return when if when.tzinfo else when.replace(tzinfo=UTC)
95
+
96
+
97
+ @dataclass(frozen=True)
98
+ class Filters:
99
+ """The filters history and stream share. ``None`` / empty = not sent."""
100
+
101
+ level: str | None = None
102
+ text: str | None = None
103
+ run_id: str | None = None
104
+ correlation_id: str | None = None
105
+ version: str | None = None
106
+ container: str | None = None
107
+ pods: tuple[str, ...] = field(default_factory=tuple)
108
+
109
+ def __post_init__(self) -> None:
110
+ if self.level is not None and self.level not in LEVELS:
111
+ raise LogsError(f"--level must be one of {', '.join(LEVELS)}")
112
+ if self.container is not None and self.container not in ("agent", "worker"):
113
+ raise LogsError("--container must be agent or worker")
114
+
115
+ def params(self) -> list[tuple[str, str]]:
116
+ out: list[tuple[str, str]] = []
117
+ for key, value in (
118
+ ("level", self.level),
119
+ ("q", self.text),
120
+ ("run_id", self.run_id),
121
+ ("correlation_id", self.correlation_id),
122
+ ("version", self.version),
123
+ ("container", self.container),
124
+ ):
125
+ if value:
126
+ out.append((key, value))
127
+ out.extend(("pod", p) for p in self.pods)
128
+ return out
129
+
130
+
131
+ def explain(resp: httpx.Response) -> str:
132
+ """One line a person (or a coding agent) can act on, from the service's error envelope."""
133
+ try:
134
+ err = (resp.json() or {}).get("error") or {}
135
+ except ValueError:
136
+ err = {}
137
+ code, message = err.get("code", ""), err.get("message") or resp.text[:200]
138
+ hints = {
139
+ 401: "your session is not accepted here — run `keystone login` again",
140
+ 403: "you need trace:read on this agent (the same right that shows its traces)",
141
+ 404: "no such agent for you in this workspace",
142
+ 429: f"rate limited — retry in {resp.headers.get('Retry-After', 'a few')} s",
143
+ 503: "log search is not configured in this environment",
144
+ }
145
+ hint = hints.get(resp.status_code)
146
+ head = f"{resp.status_code} {code}".strip()
147
+ return f"{head}: {message}" + (f" — {hint}" if hint else "")
148
+
149
+
150
+ def fetch_history(
151
+ client: httpx.Client,
152
+ base: str,
153
+ headers: dict[str, str],
154
+ agent_id: str,
155
+ *,
156
+ start: datetime,
157
+ end: datetime,
158
+ filters: Filters,
159
+ limit: int,
160
+ ) -> tuple[list[dict[str, Any]], bool]:
161
+ """Up to ``limit`` lines in the window, OLDEST first (as a terminal reads), following
162
+ ``next_before`` across pages. Returns ``(lines, more)`` — ``more`` when the window held more
163
+ than ``limit`` (or the service truncated it), so the caller can say so."""
164
+ if end <= start:
165
+ raise LogsError("--since must be before --until")
166
+ if end - start > MAX_RANGE:
167
+ raise LogsError("the window is wider than 7 days — narrow --since/--until")
168
+ url = base + LOGS_PATH.format(agent_id=agent_id)
169
+ lines: list[dict[str, Any]] = []
170
+ before: str | None = None
171
+ truncated = False
172
+ while len(lines) < limit:
173
+ params = [("from", iso(start)), ("to", iso(end)), ("limit", str(min(MAX_PAGE, limit - len(lines))))]
174
+ if before:
175
+ params.append(("before", before))
176
+ resp = client.get(url, headers=headers, params=[*params, *filters.params()])
177
+ if resp.status_code != 200:
178
+ raise LogsError(explain(resp))
179
+ body = resp.json() or {}
180
+ page = body.get("data") or []
181
+ meta = body.get("meta") or {}
182
+ lines.extend(page)
183
+ before = meta.get("next_before")
184
+ truncated = truncated or bool(meta.get("truncated"))
185
+ if not page or not before:
186
+ before = None
187
+ break
188
+ more = truncated or before is not None or len(lines) > limit
189
+ lines = lines[:limit]
190
+ lines.reverse()
191
+ return lines, more
192
+
193
+
194
+ def fetch_run_history(
195
+ client: httpx.Client,
196
+ base: str,
197
+ headers: dict[str, str],
198
+ agent_id: str,
199
+ *,
200
+ start: datetime,
201
+ end: datetime,
202
+ filters: Filters,
203
+ run_id: str,
204
+ trace_id: str | None,
205
+ limit: int,
206
+ ) -> tuple[list[dict[str, Any]], bool]:
207
+ """A run's lines, which no single filter finds (measured on dev, 2026-10-10):
208
+
209
+ * the runtime's own lines (both containers) carry ``run_id``; the background worker's carry
210
+ no correlation id;
211
+ * the agent author's own logging calls in a synchronous run carry the request's correlation
212
+ id (== the run's trace id) but no ``run_id``.
213
+
214
+ So it asks by ``run_id`` and, when the run has a trace id, by that too, and merges the two by
215
+ line id — oldest first, the newest ``limit`` kept."""
216
+ by_run, more_run = fetch_history(
217
+ client,
218
+ base,
219
+ headers,
220
+ agent_id,
221
+ start=start,
222
+ end=end,
223
+ filters=dataclasses.replace(filters, run_id=run_id, correlation_id=None),
224
+ limit=limit,
225
+ )
226
+ if not trace_id:
227
+ return by_run, more_run
228
+ by_trace, more_trace = fetch_history(
229
+ client,
230
+ base,
231
+ headers,
232
+ agent_id,
233
+ start=start,
234
+ end=end,
235
+ filters=dataclasses.replace(filters, run_id=None, correlation_id=trace_id),
236
+ limit=limit,
237
+ )
238
+ merged = {str(line.get("id")): line for line in [*by_run, *by_trace]}
239
+ epoch = datetime(1970, 1, 1, tzinfo=UTC) # by parsed time: mixed-precision ISO stamps mis-sort as text
240
+ lines = sorted(merged.values(), key=lambda ln: (parse_stamp(ln.get("timestamp")) or epoch, str(ln.get("id"))))
241
+ more = more_run or more_trace or len(lines) > limit
242
+ return lines[-limit:], more
243
+
244
+
245
+ def iter_sse(chunks: Iterator[str]) -> Iterator[tuple[str, str]]:
246
+ """``(event, data)`` from a text/event-stream. Comments (``: keep-alive``) surface as
247
+ ``("keep-alive", "")`` so a long-running reader still gets a chance to check its deadline."""
248
+ event, data = "message", []
249
+ for raw in chunks:
250
+ line = raw.rstrip("\r")
251
+ if line == "":
252
+ if data:
253
+ yield event, "\n".join(data)
254
+ event, data = "message", []
255
+ continue
256
+ if line.startswith(":"):
257
+ yield "keep-alive", ""
258
+ continue
259
+ key, _, value = line.partition(":")
260
+ value = value[1:] if value.startswith(" ") else value
261
+ if key == "event":
262
+ event = value
263
+ elif key == "data":
264
+ data.append(value)
265
+
266
+
267
+ @dataclass
268
+ class FollowState:
269
+ since: datetime
270
+ seen: set[str] = field(default_factory=set)
271
+ #: How many reconnects in a row ended before a single line arrived; caps a tight loop.
272
+ empty_reconnects: int = 0
273
+
274
+
275
+ def follow(
276
+ client: httpx.Client,
277
+ base: str,
278
+ headers: dict[str, str],
279
+ agent_id: str,
280
+ *,
281
+ state: FollowState,
282
+ filters: Filters,
283
+ on_line: Callable[[dict[str, Any]], None],
284
+ should_stop: Callable[[], bool],
285
+ remaining: Callable[[], float | None] = lambda: None,
286
+ max_reconnects: int = 3,
287
+ ) -> str:
288
+ """Tail the stream until ``should_stop()`` says so (checked on every event and keep-alive).
289
+
290
+ The service closes a stream after ~15 min with ``end``; this reconnects from the last line's
291
+ timestamp and drops the overlap by line id, so the user sees one continuous tail. Returns why it
292
+ stopped: ``"stopped"`` (should_stop), or raises :class:`LogsError` on a refusal / ``error``.
293
+
294
+ ``remaining()`` — seconds left before a deadline, or None. The stream only wakes this loop on an
295
+ event or a keep-alive (~15 s), and every keep-alive resets a read timeout, so neither honours a
296
+ deadline (measured: ``--timeout 20`` ran 31 s). Instead the response is CLOSED when the time is
297
+ up — a timer on the connection — and the read that was waiting returns at once. The read
298
+ timeout still bounds the wait for the response headers (the server backfills before sending
299
+ them). An idle stream is never torn down early, so no extra reconnects (each costs a Splunk
300
+ backfill and a rate-limit slot)."""
301
+ url = base + STREAM_PATH.format(agent_id=agent_id)
302
+ while True:
303
+ params = [("since", iso(state.since)), *filters.params()]
304
+ left = remaining()
305
+ if left is not None and left <= 0:
306
+ return "stopped"
307
+ timeout = httpx.Timeout(30.0, read=None if left is None else max(0.5, min(left, 60.0)))
308
+ try:
309
+ reason = _one_stream(
310
+ client,
311
+ url,
312
+ headers,
313
+ params,
314
+ timeout=timeout,
315
+ close_after=left,
316
+ state=state,
317
+ on_line=on_line,
318
+ should_stop=should_stop,
319
+ )
320
+ except httpx.ReadTimeout:
321
+ reason = "stopped" if should_stop() else "timeout"
322
+ if reason == "stopped" or should_stop():
323
+ return "stopped"
324
+ if reason == "timeout":
325
+ continue # the deadline is still ahead (read timeout capped at 60 s): reopen and keep going
326
+ state.empty_reconnects = 0 if reason == "got-line" else state.empty_reconnects + 1
327
+ if state.empty_reconnects > max_reconnects:
328
+ raise LogsError("the log stream keeps closing without sending anything — try again later")
329
+
330
+
331
+ def _one_stream(
332
+ client: httpx.Client,
333
+ url: str,
334
+ headers: dict[str, str],
335
+ params: list[tuple[str, str]],
336
+ *,
337
+ timeout: httpx.Timeout,
338
+ close_after: float | None,
339
+ state: FollowState,
340
+ on_line: Callable[[dict[str, Any]], None],
341
+ should_stop: Callable[[], bool],
342
+ ) -> str:
343
+ """One SSE connection: ``"stopped"``, ``"got-line"`` or ``"empty"`` when the server ends it.
344
+ With ``close_after`` the connection is closed from a timer when that many seconds pass."""
345
+ with client.stream(
346
+ "GET", url, headers={**headers, "Accept": "text/event-stream"}, params=params, timeout=timeout
347
+ ) as resp:
348
+ if resp.status_code != 200:
349
+ resp.read()
350
+ raise LogsError(explain(resp))
351
+ expired = threading.Event()
352
+
353
+ def _expire() -> None:
354
+ expired.set()
355
+ # resp.close() alone does NOT interrupt a read already blocked in recv() on another
356
+ # thread (measured: it returned only at the next keep-alive, ~15 s late). Shutting the
357
+ # socket down does: recv() returns at once and the iteration below ends.
358
+ stream = resp.extensions.get("network_stream")
359
+ sock = stream.get_extra_info("socket") if stream is not None else None
360
+ if sock is not None:
361
+ with contextlib.suppress(OSError):
362
+ sock.shutdown(socket.SHUT_RDWR)
363
+ resp.close()
364
+
365
+ timer = threading.Timer(close_after, _expire) if close_after is not None else None
366
+ if timer is not None:
367
+ timer.daemon = True
368
+ timer.start()
369
+ try:
370
+ return _read_events(resp, state=state, on_line=on_line, should_stop=should_stop)
371
+ except Exception:
372
+ if expired.is_set():
373
+ return "stopped" # we closed it: the deadline, not a failure
374
+ raise
375
+ finally:
376
+ if timer is not None:
377
+ timer.cancel()
378
+
379
+
380
+ def _read_events(
381
+ resp: httpx.Response,
382
+ *,
383
+ state: FollowState,
384
+ on_line: Callable[[dict[str, Any]], None],
385
+ should_stop: Callable[[], bool],
386
+ ) -> str:
387
+ got_line = False
388
+ for event, data in iter_sse(resp.iter_lines()):
389
+ if event == "line":
390
+ line = json.loads(data)
391
+ line_id = str(line.get("id", ""))
392
+ if line_id and line_id in state.seen:
393
+ continue
394
+ state.seen.add(line_id)
395
+ got_line = True
396
+ stamp = parse_stamp(line.get("timestamp"))
397
+ if stamp and stamp > state.since:
398
+ state.since = stamp
399
+ on_line(line)
400
+ elif event == "error":
401
+ payload = json.loads(data) if data else {}
402
+ raise LogsError(f"stream error: {payload.get('message') or payload}")
403
+ elif event == "end":
404
+ break
405
+ if should_stop():
406
+ return "stopped"
407
+ return "got-line" if got_line else "empty"
408
+
409
+
410
+ _HEAD_CHARS = 300
411
+
412
+
413
+ def clip(line: dict[str, Any], max_chars: int) -> dict[str, Any]:
414
+ """A copy of ``line`` with the message and every string field cut to ``max_chars`` (0 = whole).
415
+
416
+ Keeps the first few hundred characters and the END: a traceback's last frames and the
417
+ exception itself are what a reader — or a coding agent paying per token — needs, and the
418
+ middle frames (framework plumbing) are what a long one is mostly made of."""
419
+ if max_chars <= 0:
420
+ return line
421
+
422
+ def cut(value: Any) -> Any:
423
+ if not isinstance(value, str) or len(value) <= max_chars:
424
+ return value
425
+ head = min(_HEAD_CHARS, max_chars // 3)
426
+ tail = max_chars - head
427
+ dropped = len(value) - head - tail
428
+ return f"{value[:head]}\n… [{dropped} chars cut — --max-field-chars 0 for all] …\n{value[-tail:]}"
429
+
430
+ out = dict(line)
431
+ out["message"] = cut(line.get("message"))
432
+ if isinstance(line.get("fields"), dict):
433
+ out["fields"] = {k: cut(v) for k, v in line["fields"].items()}
434
+ return out
435
+
436
+
437
+ def render(line: dict[str, Any]) -> str:
438
+ """``2026-10-10T08:01:02.123Z ERROR [agent] message run=1a2b3c4d k=v`` — one line per entry,
439
+ multi-line messages (tracebacks) indented under it so a grep for the header still works."""
440
+ level = str(line.get("level") or "info").upper()
441
+ container = line.get("container")
442
+ head = f"{line.get('timestamp', '')} {level:<8}" + (f" [{container}]" if container else "")
443
+ extras: list[str] = []
444
+ blocks: list[str] = []
445
+ if run := line.get("run_id"):
446
+ extras.append(f"run={str(run)[:8]}")
447
+ if ver := line.get("version"):
448
+ extras.append(f"ver={ver}")
449
+ for key, value in (line.get("fields") or {}).items():
450
+ text_value = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
451
+ if "\n" in text_value:
452
+ # A traceback (structlog's `exception`) is unreadable folded into the header line.
453
+ blocks.append(f" {key}:\n" + "\n".join(f" {ln}" for ln in text_value.splitlines()))
454
+ else:
455
+ extras.append(f"{key}={text_value}")
456
+ message = str(line.get("message") or "")
457
+ first, *rest = message.splitlines() or [""]
458
+ text = f"{head} {first}" + (f" {' '.join(extras)}" if extras else "")
459
+ if rest:
460
+ text += "\n" + "\n".join(f" {r}" for r in rest)
461
+ if blocks:
462
+ text += "\n" + "\n".join(blocks)
463
+ return text
@@ -0,0 +1,36 @@
1
+ """Claude Code skills shipped with the CLI — the agent-native half of the developer experience.
2
+
3
+ A skill is a ``SKILL.md`` a coding agent loads on demand (``.claude/skills/<name>/SKILL.md`` in the
4
+ project). ``keystone-debug`` teaches the loop this CLI supports end to end — invoke → ``logs --run``
5
+ → ``runs trace`` → fix → redeploy — and how to read the platform's error codes. LangSmith reported
6
+ Claude Code going from 17% to 92% on their own tasks once given skills; the CLI alone is not enough
7
+ for an agent to know which command answers which question.
8
+
9
+ Skills ship as PACKAGE DATA (``keystone_cli/skills/<name>/SKILL.md``), like the templates, so
10
+ ``agent init`` and ``skills install`` work offline and always match the installed CLI's commands.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from importlib import resources
16
+ from pathlib import Path
17
+
18
+ SKILLS = ("keystone-debug",)
19
+
20
+
21
+ def install(project: Path, *, force: bool = False) -> list[tuple[str, Path, bool]]:
22
+ """Write every shipped skill into ``project/.claude/skills/``.
23
+
24
+ Returns ``(name, path, written)`` per skill. An existing file is left alone unless ``force`` —
25
+ the team may have edited it, and a CLI upgrade must not silently throw that away."""
26
+ out: list[tuple[str, Path, bool]] = []
27
+ for name in SKILLS:
28
+ target = project / ".claude" / "skills" / name / "SKILL.md"
29
+ if target.exists() and not force:
30
+ out.append((name, target, False))
31
+ continue
32
+ text = resources.files("keystone_cli").joinpath("skills", name, "SKILL.md").read_text(encoding="utf-8")
33
+ target.parent.mkdir(parents=True, exist_ok=True)
34
+ target.write_text(text, encoding="utf-8")
35
+ out.append((name, target, True))
36
+ return out