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.
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/PKG-INFO +3 -3
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/pyproject.toml +6 -6
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/__main__.py +10 -2
- keystone_cli-0.2.0/src/keystone_cli/agent_logs.py +463 -0
- keystone_cli-0.2.0/src/keystone_cli/agent_skills.py +36 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/commands/agent.py +1209 -122
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/commands/dev.py +6 -1
- keystone_cli-0.2.0/src/keystone_cli/commands/kb.py +286 -0
- keystone_cli-0.2.0/src/keystone_cli/commands/kb_metadata.py +392 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/commands/login.py +17 -3
- keystone_cli-0.2.0/src/keystone_cli/commands/skills.py +40 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/commands/tool_credentials.py +67 -13
- keystone_cli-0.2.0/src/keystone_cli/commands/whoami.py +41 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/commands/workspace.py +21 -4
- keystone_cli-0.2.0/src/keystone_cli/run_debug.py +123 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/runner.py +131 -4
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/scaffold.py +6 -0
- keystone_cli-0.2.0/src/keystone_cli/skills/keystone-debug/SKILL.md +65 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/blank/README.md.tmpl +3 -1
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/hitl/README.md.tmpl +35 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/hitl/agent.yaml.tmpl +2 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/hitl/pkg/graph.py.tmpl +3 -0
- keystone_cli-0.2.0/src/keystone_cli/templates/kb-builder/README.md.tmpl +53 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/kb-builder/agent.yaml.tmpl +21 -4
- keystone_cli-0.2.0/src/keystone_cli/templates/kb-builder/pkg/graph.py.tmpl +139 -0
- keystone_cli-0.2.0/src/keystone_cli/templates/kb-builder/pyproject.toml.tmpl +12 -0
- keystone_cli-0.2.0/src/keystone_cli/templates/llm-streaming/README.md.tmpl +77 -0
- keystone_cli-0.2.0/src/keystone_cli/templates/ocr/README.md.tmpl +57 -0
- keystone_cli-0.2.0/src/keystone_cli/templates/ocr/agent.yaml.tmpl +49 -0
- keystone_cli-0.2.0/src/keystone_cli/templates/ocr/pkg/graph.py.tmpl +112 -0
- keystone_cli-0.2.0/src/keystone_cli/templates/ocr/pyproject.toml.tmpl +12 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/rag-qa/agent.yaml.tmpl +3 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/rag-qa/pkg/graph.py.tmpl +14 -1
- keystone_cli-0.2.0/src/keystone_cli/templates/rag-qa/pyproject.toml.tmpl +12 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/tool-agent/README.md.tmpl +2 -1
- keystone_cli-0.2.0/src/keystone_cli/templates/tool-agent/env.tmpl +10 -0
- keystone_cli-0.2.0/src/keystone_cli/templates/tool-agent/gitignore.tmpl +7 -0
- keystone_cli-0.2.0/src/keystone_cli/templates/tool-agent/pkg/__init__.py.tmpl +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli.egg-info/PKG-INFO +3 -3
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli.egg-info/SOURCES.txt +23 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli.egg-info/requires.txt +1 -1
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/tests/test_deploy.py +73 -1
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/tests/test_doctor.py +65 -2
- keystone_cli-0.2.0/tests/test_help_quality.py +103 -0
- keystone_cli-0.2.0/tests/test_hitl_approver_cli.py +129 -0
- keystone_cli-0.2.0/tests/test_hitl_decisions_cli.py +139 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/tests/test_init.py +104 -0
- keystone_cli-0.2.0/tests/test_invoke_stream.py +206 -0
- keystone_cli-0.2.0/tests/test_kb_commands.py +167 -0
- keystone_cli-0.2.0/tests/test_kb_metadata_commands.py +311 -0
- keystone_cli-0.2.0/tests/test_logs.py +538 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/tests/test_offline.py +58 -0
- keystone_cli-0.2.0/tests/test_run_debug.py +198 -0
- keystone_cli-0.2.0/tests/test_skills.py +83 -0
- keystone_cli-0.1.27/src/keystone_cli/commands/kb.py +0 -144
- keystone_cli-0.1.27/src/keystone_cli/templates/kb-builder/README.md.tmpl +0 -23
- keystone_cli-0.1.27/src/keystone_cli/templates/kb-builder/pkg/graph.py.tmpl +0 -73
- keystone_cli-0.1.27/src/keystone_cli/templates/llm-streaming/README.md.tmpl +0 -46
- keystone_cli-0.1.27/src/keystone_cli/templates/rag-qa/pyproject.toml.tmpl +0 -12
- keystone_cli-0.1.27/src/keystone_cli/templates/skill/pyproject.toml.tmpl +0 -12
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/setup.cfg +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/__init__.py +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/_build_env.py +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/_stdio.py +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/_version.py +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/auth/__init__.py +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/auth/device_flow.py +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/auth/token_store.py +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/commands/__init__.py +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/devloop.py +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/platform_client.py +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/blank/agent.yaml.tmpl +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/blank/env.tmpl +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/blank/gitignore.tmpl +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/blank/pkg/__init__.py.tmpl +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/blank/pkg/graph.py.tmpl +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/blank/pyproject.toml.tmpl +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/guardrails/README.md.tmpl +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/guardrails/agent.yaml.tmpl +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/guardrails/env.tmpl +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/guardrails/gitignore.tmpl +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/guardrails/pkg/__init__.py.tmpl +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/guardrails/pkg/graph.py.tmpl +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/guardrails/pyproject.toml.tmpl +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/hitl/env.tmpl +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/hitl/gitignore.tmpl +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/hitl/pkg/__init__.py.tmpl +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/hitl/pyproject.toml.tmpl +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/kb-builder/env.tmpl +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/kb-builder/gitignore.tmpl +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/kb-builder/pkg/__init__.py.tmpl +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/llm/README.md.tmpl +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/llm/agent.yaml.tmpl +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/llm/env.tmpl +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/llm/gitignore.tmpl +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/llm/pkg/__init__.py.tmpl +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/llm/pkg/graph.py.tmpl +0 -0
- {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
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/llm-streaming/agent.yaml.tmpl +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/llm-streaming/env.tmpl +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/llm-streaming/gitignore.tmpl +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/llm-streaming/pkg/__init__.py.tmpl +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/llm-streaming/pkg/graph.py.tmpl +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/llm-streaming/pyproject.toml.tmpl +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/memory/README.md.tmpl +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/memory/agent.yaml.tmpl +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/memory/env.tmpl +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/memory/gitignore.tmpl +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/memory/pkg/__init__.py.tmpl +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/memory/pkg/graph.py.tmpl +0 -0
- {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
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/memory-service/README.md.tmpl +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/memory-service/agent.yaml.tmpl +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/memory-service/env.tmpl +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/memory-service/gitignore.tmpl +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/memory-service/pkg/__init__.py.tmpl +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/memory-service/pkg/graph.py.tmpl +0 -0
- {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
- {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
- {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
- {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
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/rag-qa/README.md.tmpl +0 -0
- {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
- {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
- {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
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/skill/README.md.tmpl +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/skill/agent.yaml.tmpl +0 -0
- {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
- {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
- {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
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/skill/pkg/graph.py.tmpl +0 -0
- {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
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/tool-agent/agent.yaml.tmpl +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/tool-agent/pkg/graph.py.tmpl +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli/templates/tool-agent/pyproject.toml.tmpl +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli.egg-info/dependency_links.txt +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli.egg-info/entry_points.txt +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/src/keystone_cli.egg-info/top_level.txt +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/tests/test_agent.py +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/tests/test_assistant_provenance_and_flags.py +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/tests/test_auth.py +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/tests/test_build_progress.py +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/tests/test_ci_token.py +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/tests/test_config_env_mismatch.py +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/tests/test_dev.py +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/tests/test_encoding.py +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/tests/test_invoke.py +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/tests/test_login_command.py +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/tests/test_platform_client.py +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/tests/test_publish.py +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/tests/test_retire_delete.py +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/tests/test_runs.py +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/tests/test_status.py +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/tests/test_stdio.py +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/tests/test_threads.py +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/tests/test_tool_credentials.py +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/tests/test_traces.py +0 -0
- {keystone_cli-0.1.27 → keystone_cli-0.2.0}/tests/test_version.py +0 -0
- {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.
|
|
4
|
-
Summary: Keystone pro-code agent CLI —
|
|
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.
|
|
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.
|
|
8
|
-
description = "Keystone pro-code agent CLI —
|
|
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.
|
|
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.
|
|
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=
|
|
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
|
|
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
|
|
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
|