keystone-cli 0.1.9__tar.gz → 0.1.16__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 (123) hide show
  1. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/PKG-INFO +2 -2
  2. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/pyproject.toml +3 -3
  3. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/__main__.py +2 -0
  4. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/auth/token_store.py +53 -5
  5. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/commands/agent.py +313 -12
  6. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/commands/dev.py +1 -0
  7. keystone_cli-0.1.16/src/keystone_cli/commands/kb.py +144 -0
  8. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/devloop.py +38 -6
  9. keystone_cli-0.1.16/src/keystone_cli/runner.py +594 -0
  10. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/scaffold.py +28 -7
  11. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/templates/blank/agent.yaml.tmpl +3 -0
  12. keystone_cli-0.1.16/src/keystone_cli/templates/guardrails/README.md.tmpl +63 -0
  13. keystone_cli-0.1.16/src/keystone_cli/templates/guardrails/agent.yaml.tmpl +53 -0
  14. keystone_cli-0.1.16/src/keystone_cli/templates/guardrails/pkg/graph.py.tmpl +102 -0
  15. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/templates/hitl/agent.yaml.tmpl +8 -0
  16. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/templates/hitl/pkg/graph.py.tmpl +5 -9
  17. keystone_cli-0.1.16/src/keystone_cli/templates/kb-builder/README.md.tmpl +23 -0
  18. keystone_cli-0.1.16/src/keystone_cli/templates/kb-builder/agent.yaml.tmpl +45 -0
  19. keystone_cli-0.1.16/src/keystone_cli/templates/kb-builder/pkg/graph.py.tmpl +73 -0
  20. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/templates/llm/agent.yaml.tmpl +8 -0
  21. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/templates/llm/pkg/graph.py.tmpl +5 -9
  22. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/templates/memory/agent.yaml.tmpl +3 -0
  23. keystone_cli-0.1.16/src/keystone_cli/templates/memory/env.tmpl +10 -0
  24. keystone_cli-0.1.16/src/keystone_cli/templates/memory/gitignore.tmpl +7 -0
  25. keystone_cli-0.1.16/src/keystone_cli/templates/memory/pkg/__init__.py.tmpl +0 -0
  26. keystone_cli-0.1.16/src/keystone_cli/templates/memory/pyproject.toml.tmpl +12 -0
  27. keystone_cli-0.1.16/src/keystone_cli/templates/memory-service/README.md.tmpl +70 -0
  28. keystone_cli-0.1.16/src/keystone_cli/templates/memory-service/agent.yaml.tmpl +36 -0
  29. keystone_cli-0.1.16/src/keystone_cli/templates/memory-service/pkg/graph.py.tmpl +98 -0
  30. keystone_cli-0.1.16/src/keystone_cli/templates/memory-service/pyproject.toml.tmpl +12 -0
  31. keystone_cli-0.1.16/src/keystone_cli/templates/rag-qa/agent.yaml.tmpl +56 -0
  32. keystone_cli-0.1.16/src/keystone_cli/templates/rag-qa/env.tmpl +10 -0
  33. keystone_cli-0.1.16/src/keystone_cli/templates/rag-qa/gitignore.tmpl +7 -0
  34. keystone_cli-0.1.16/src/keystone_cli/templates/rag-qa/pkg/__init__.py.tmpl +0 -0
  35. keystone_cli-0.1.16/src/keystone_cli/templates/rag-qa/pkg/graph.py.tmpl +73 -0
  36. keystone_cli-0.1.16/src/keystone_cli/templates/rag-qa/pyproject.toml.tmpl +12 -0
  37. keystone_cli-0.1.16/src/keystone_cli/templates/skill/README.md.tmpl +52 -0
  38. keystone_cli-0.1.16/src/keystone_cli/templates/skill/agent.yaml.tmpl +30 -0
  39. keystone_cli-0.1.16/src/keystone_cli/templates/skill/env.tmpl +10 -0
  40. keystone_cli-0.1.16/src/keystone_cli/templates/skill/gitignore.tmpl +7 -0
  41. keystone_cli-0.1.16/src/keystone_cli/templates/skill/pkg/__init__.py.tmpl +0 -0
  42. keystone_cli-0.1.16/src/keystone_cli/templates/skill/pkg/graph.py.tmpl +82 -0
  43. keystone_cli-0.1.16/src/keystone_cli/templates/skill/pyproject.toml.tmpl +12 -0
  44. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/templates/tool-agent/agent.yaml.tmpl +8 -0
  45. keystone_cli-0.1.16/src/keystone_cli/templates/tool-agent/env.tmpl +10 -0
  46. keystone_cli-0.1.16/src/keystone_cli/templates/tool-agent/gitignore.tmpl +7 -0
  47. keystone_cli-0.1.16/src/keystone_cli/templates/tool-agent/pkg/__init__.py.tmpl +0 -0
  48. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/templates/tool-agent/pkg/graph.py.tmpl +5 -5
  49. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli.egg-info/PKG-INFO +2 -2
  50. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli.egg-info/SOURCES.txt +32 -0
  51. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli.egg-info/requires.txt +1 -1
  52. keystone_cli-0.1.16/tests/test_assistant_provenance_and_flags.py +180 -0
  53. keystone_cli-0.1.16/tests/test_auth.py +238 -0
  54. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/tests/test_deploy.py +116 -1
  55. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/tests/test_dev.py +1 -1
  56. keystone_cli-0.1.16/tests/test_doctor.py +142 -0
  57. keystone_cli-0.1.16/tests/test_encoding.py +60 -0
  58. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/tests/test_init.py +48 -1
  59. keystone_cli-0.1.16/tests/test_offline.py +348 -0
  60. keystone_cli-0.1.9/src/keystone_cli/runner.py +0 -87
  61. keystone_cli-0.1.9/src/keystone_cli/templates/rag-qa/agent.yaml.tmpl +0 -34
  62. keystone_cli-0.1.9/src/keystone_cli/templates/rag-qa/pkg/graph.py.tmpl +0 -69
  63. keystone_cli-0.1.9/tests/test_auth.py +0 -118
  64. keystone_cli-0.1.9/tests/test_offline.py +0 -34
  65. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/setup.cfg +0 -0
  66. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/__init__.py +0 -0
  67. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/_build_env.py +0 -0
  68. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/_version.py +0 -0
  69. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/auth/__init__.py +0 -0
  70. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/auth/device_flow.py +0 -0
  71. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/commands/__init__.py +0 -0
  72. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/commands/login.py +0 -0
  73. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/commands/workspace.py +0 -0
  74. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/platform_client.py +0 -0
  75. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/templates/blank/README.md.tmpl +0 -0
  76. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/templates/blank/env.tmpl +0 -0
  77. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/templates/blank/gitignore.tmpl +0 -0
  78. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/templates/blank/pkg/__init__.py.tmpl +0 -0
  79. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/templates/blank/pkg/graph.py.tmpl +0 -0
  80. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/templates/blank/pyproject.toml.tmpl +0 -0
  81. {keystone_cli-0.1.9/src/keystone_cli/templates/hitl → keystone_cli-0.1.16/src/keystone_cli/templates/guardrails}/env.tmpl +0 -0
  82. {keystone_cli-0.1.9/src/keystone_cli/templates/hitl → keystone_cli-0.1.16/src/keystone_cli/templates/guardrails}/gitignore.tmpl +0 -0
  83. {keystone_cli-0.1.9/src/keystone_cli/templates/hitl → keystone_cli-0.1.16/src/keystone_cli/templates/guardrails}/pkg/__init__.py.tmpl +0 -0
  84. {keystone_cli-0.1.9/src/keystone_cli/templates/hitl → keystone_cli-0.1.16/src/keystone_cli/templates/guardrails}/pyproject.toml.tmpl +0 -0
  85. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/templates/hitl/README.md.tmpl +0 -0
  86. {keystone_cli-0.1.9/src/keystone_cli/templates/llm → keystone_cli-0.1.16/src/keystone_cli/templates/hitl}/env.tmpl +0 -0
  87. {keystone_cli-0.1.9/src/keystone_cli/templates/llm → keystone_cli-0.1.16/src/keystone_cli/templates/hitl}/gitignore.tmpl +0 -0
  88. {keystone_cli-0.1.9/src/keystone_cli/templates/llm → keystone_cli-0.1.16/src/keystone_cli/templates/hitl}/pkg/__init__.py.tmpl +0 -0
  89. {keystone_cli-0.1.9/src/keystone_cli/templates/llm → keystone_cli-0.1.16/src/keystone_cli/templates/hitl}/pyproject.toml.tmpl +0 -0
  90. {keystone_cli-0.1.9/src/keystone_cli/templates/memory → keystone_cli-0.1.16/src/keystone_cli/templates/kb-builder}/env.tmpl +0 -0
  91. {keystone_cli-0.1.9/src/keystone_cli/templates/memory → keystone_cli-0.1.16/src/keystone_cli/templates/kb-builder}/gitignore.tmpl +0 -0
  92. {keystone_cli-0.1.9/src/keystone_cli/templates/memory → keystone_cli-0.1.16/src/keystone_cli/templates/kb-builder}/pkg/__init__.py.tmpl +0 -0
  93. {keystone_cli-0.1.9/src/keystone_cli/templates/memory → keystone_cli-0.1.16/src/keystone_cli/templates/kb-builder}/pyproject.toml.tmpl +0 -0
  94. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/templates/llm/README.md.tmpl +0 -0
  95. {keystone_cli-0.1.9/src/keystone_cli/templates/rag-qa → keystone_cli-0.1.16/src/keystone_cli/templates/llm}/env.tmpl +0 -0
  96. {keystone_cli-0.1.9/src/keystone_cli/templates/rag-qa → keystone_cli-0.1.16/src/keystone_cli/templates/llm}/gitignore.tmpl +0 -0
  97. {keystone_cli-0.1.9/src/keystone_cli/templates/rag-qa → keystone_cli-0.1.16/src/keystone_cli/templates/llm}/pkg/__init__.py.tmpl +0 -0
  98. {keystone_cli-0.1.9/src/keystone_cli/templates/rag-qa → keystone_cli-0.1.16/src/keystone_cli/templates/llm}/pyproject.toml.tmpl +0 -0
  99. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/templates/memory/README.md.tmpl +0 -0
  100. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/templates/memory/pkg/graph.py.tmpl +0 -0
  101. {keystone_cli-0.1.9/src/keystone_cli/templates/tool-agent → keystone_cli-0.1.16/src/keystone_cli/templates/memory-service}/env.tmpl +0 -0
  102. {keystone_cli-0.1.9/src/keystone_cli/templates/tool-agent → keystone_cli-0.1.16/src/keystone_cli/templates/memory-service}/gitignore.tmpl +0 -0
  103. {keystone_cli-0.1.9/src/keystone_cli/templates/tool-agent → keystone_cli-0.1.16/src/keystone_cli/templates/memory-service}/pkg/__init__.py.tmpl +0 -0
  104. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/templates/rag-qa/README.md.tmpl +0 -0
  105. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/templates/tool-agent/README.md.tmpl +0 -0
  106. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli/templates/tool-agent/pyproject.toml.tmpl +0 -0
  107. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli.egg-info/dependency_links.txt +0 -0
  108. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli.egg-info/entry_points.txt +0 -0
  109. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/src/keystone_cli.egg-info/top_level.txt +0 -0
  110. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/tests/test_agent.py +0 -0
  111. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/tests/test_build_progress.py +0 -0
  112. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/tests/test_ci_token.py +0 -0
  113. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/tests/test_invoke.py +0 -0
  114. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/tests/test_login_command.py +0 -0
  115. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/tests/test_platform_client.py +0 -0
  116. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/tests/test_publish.py +0 -0
  117. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/tests/test_retire_delete.py +0 -0
  118. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/tests/test_runs.py +0 -0
  119. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/tests/test_status.py +0 -0
  120. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/tests/test_threads.py +0 -0
  121. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/tests/test_traces.py +0 -0
  122. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/tests/test_version.py +0 -0
  123. {keystone_cli-0.1.9 → keystone_cli-0.1.16}/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.9
3
+ Version: 0.1.16
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.6,>=0.5
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.9"
7
+ version = "0.1.16"
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.5,<0.6",
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 = "py312"
48
+ target-version = "0.1.16"
49
49
  line-length = 120
50
50
 
51
51
  [tool.ruff.lint]
@@ -7,6 +7,7 @@ import typer
7
7
  from keystone_cli._version import version_line
8
8
  from keystone_cli.commands.agent import agent_app
9
9
  from keystone_cli.commands.dev import dev
10
+ from keystone_cli.commands.kb import kb_app
10
11
  from keystone_cli.commands.login import login
11
12
  from keystone_cli.commands.workspace import workspace_app
12
13
 
@@ -45,6 +46,7 @@ def version() -> None:
45
46
 
46
47
  app.add_typer(agent_app, name="agent")
47
48
  app.add_typer(workspace_app, name="workspace")
49
+ app.add_typer(kb_app, name="kb")
48
50
  app.command(name="login")(login)
49
51
  app.command(name="dev")(dev)
50
52
 
@@ -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)
@@ -77,6 +77,11 @@ def validate(
77
77
  ) -> None:
78
78
  """Validate an agent.yaml (manifest schema + entrypoint importable). Exit 1 on errors."""
79
79
  result = sdk_validate(_manifest_path(path))
80
+ # Notes print on BOTH paths and never change the exit code — they are things that are valid but
81
+ # will not behave as the author expects (a declared block that needs a second step). Printing
82
+ # them only on success would hide them exactly when someone is already debugging.
83
+ for note in result.notes:
84
+ typer.secho(f"NOTE {note}", fg=typer.colors.YELLOW)
80
85
  if result.ok:
81
86
  typer.secho(f"✓ {path} is valid", fg=typer.colors.GREEN)
82
87
  return
@@ -112,6 +117,7 @@ def run(
112
117
  project_dir=Path(manifest_path).resolve().parent,
113
118
  inputs=inputs,
114
119
  offline=offline,
120
+ config=result.manifest.config,
115
121
  )
116
122
  typer.secho(f"\n✓ result: {json.dumps(final, default=str)}", fg=typer.colors.GREEN)
117
123
 
@@ -151,6 +157,12 @@ def deploy(
151
157
  wait_timeout: int = typer.Option(
152
158
  180, "--wait-timeout", help="Seconds to wait for the pod to become ready under --wait."
153
159
  ),
160
+ allow_secrets: bool = typer.Option(
161
+ False,
162
+ "--allow-secrets",
163
+ help="Package files that name a secret-looking variable anyway (fixtures, doc examples). "
164
+ "Does NOT override the hard block on an actual credential.",
165
+ ),
154
166
  ) -> None:
155
167
  """Package the project + upload to agent-hub for a server-side build. Requires `keystone login`.
156
168
 
@@ -172,11 +184,28 @@ def deploy(
172
184
  except SecretInBundleError as exc:
173
185
  # Fail the deploy, don't warn-and-continue: once the bundle is uploaded the key is in S3 and
174
186
  # in the image, and "we'll rotate it later" is not something a build step gets to decide.
187
+ # No flag lifts this one — the patterns it fires on are a credential and nothing else.
175
188
  typer.secho(f"✗ {exc}", fg=typer.colors.RED)
176
189
  raise typer.Exit(1) from exc
177
190
  except (NotADirectoryError, FileNotFoundError) as exc:
178
191
  typer.secho(f"✗ {exc}", fg=typer.colors.RED)
179
192
  raise typer.Exit(1) from exc
193
+ # The softer tier (FDP-4715). Matched on the variable NAME, so it can be a fixture or a doc
194
+ # example — but the default has to be "stop", because the cost is asymmetric: a needless
195
+ # --allow-secrets costs one re-run, an uploaded credential costs a rotation plus a
196
+ # blast-radius review, and the bundle outlives the agent (retire/delete cleanup is still off).
197
+ if bundle.secret_warnings and not allow_secrets:
198
+ for warning in bundle.secret_warnings:
199
+ typer.secho(f"✗ {warning.describe()}", fg=typer.colors.RED)
200
+ typer.secho(
201
+ "Not packaging these. Move the value to .env (never bundled) and let the operator "
202
+ "inject it, or reference it from agent.yaml `config:` — re-run with --allow-secrets if "
203
+ "it really is a placeholder or a fixture.",
204
+ fg=typer.colors.RED,
205
+ )
206
+ raise typer.Exit(1)
207
+ for warning in bundle.secret_warnings:
208
+ typer.secho(f"⚠ {warning.describe()} — packaged anyway (--allow-secrets).", fg=typer.colors.YELLOW)
180
209
  # Where this bundle came from. Recorded because the bundle is the WORKING DIRECTORY, not a git
181
210
  # ref: without it, `bundle_sha256` proves two uploads were identical but never says which commit
182
211
  # produced them — and says nothing at all if the tree had uncommitted edits.
@@ -437,19 +466,39 @@ _WARMING_MAX_ATTEMPTS = 6
437
466
  _WAIT_TRANSIENT_STATUSES = ("queued", "running", "cancelling", "warming")
438
467
 
439
468
 
469
+ def _assistant_note(body: dict) -> str:
470
+ """`· assistant <name>` when the run had one — FDP-4717 (2).
471
+
472
+ The runtime has recorded this since W1 (``agent_sessions.assistant_id`` / ``assistant_name``,
473
+ commented there as "metadata for run list/detail") and the API returns it on BOTH the list rows
474
+ and the detail read. Only the CLI never showed it, so from a terminal there was no way to tell
475
+ which assistant drove a run — which is most of the point of having assistants.
476
+
477
+ Falls back to the id when the name is absent (an assistant deleted after the run, say), and
478
+ returns "" when there is neither, so a plain run's output is byte-identical to before.
479
+ """
480
+ label = body.get("assistant_name") or body.get("assistant_id")
481
+ return f" · assistant {label}" if label else ""
482
+
483
+
440
484
  def _print_run_outcome(agent: str, body: dict) -> None:
441
485
  """Shared outcome printer (FDP-3332): pending statuses are NOT failures — a queued or
442
486
  HITL-paused run exits 0 with the handle to poll/respond with."""
443
487
  status = body.get("status", "?")
444
488
  run_id = body.get("run_id") or body.get("session_id", "?")
445
489
  if status in _FAILURE_STATUSES:
446
- typer.secho(f"✗ {agent} — {status}: {body.get('error') or 'no result'}", fg=typer.colors.RED)
490
+ typer.secho(
491
+ f"✗ {agent} — {status}: {body.get('error') or 'no result'}{_assistant_note(body)}", fg=typer.colors.RED
492
+ )
447
493
  raise typer.Exit(1)
448
494
  if status in _PENDING_STATUSES:
449
- typer.secho(f"… {agent} — {status} (run {run_id})", fg=typer.colors.YELLOW)
495
+ typer.secho(f"… {agent} — {status} (run {run_id}){_assistant_note(body)}", fg=typer.colors.YELLOW)
450
496
  typer.echo(f" poll with: keystone agent runs get {agent} {run_id} --wait")
451
497
  return
452
- typer.secho(f"✓ {agent} — {status} (session {body.get('session_id', run_id)})", fg=typer.colors.GREEN)
498
+ typer.secho(
499
+ f"✓ {agent} — {status} (session {body.get('session_id', run_id)}){_assistant_note(body)}",
500
+ fg=typer.colors.GREEN,
501
+ )
453
502
  typer.echo(json.dumps(body.get("result"), indent=2, default=str, ensure_ascii=False))
454
503
 
455
504
 
@@ -661,11 +710,21 @@ def runs_list(
661
710
  if not items:
662
711
  typer.echo(f"no runs for {agent}.")
663
712
  return
664
- typer.echo(f" {'RUN_ID':<36} {'STATUS':<14} {'VER':<5} CREATED")
665
- for it in items:
713
+ # FDP-4717 (2): ASSISTANT. Width from the data so a long assistant name does not shove CREATED
714
+ # off the line, and the column is DROPPED entirely when no run in the page had one — an agent
715
+ # that never uses assistants should not pay a column of dashes for the feature.
716
+ names = [str(it.get("assistant_name") or it.get("assistant_id") or "") for it in items]
717
+ awidth = max((len(n) for n in names), default=0)
718
+ if awidth:
719
+ awidth = max(awidth, len("ASSISTANT"))
720
+ typer.echo(f" {'RUN_ID':<36} {'STATUS':<14} {'VER':<5} {'ASSISTANT':<{awidth}} CREATED")
721
+ else:
722
+ typer.echo(f" {'RUN_ID':<36} {'STATUS':<14} {'VER':<5} CREATED")
723
+ for it, name in zip(items, names, strict=True):
724
+ assistant = f"{(name or '-'):<{awidth}} " if awidth else ""
666
725
  typer.echo(
667
726
  f" {it.get('run_id', ''):<36} {it.get('status', ''):<14} "
668
- f"{(it.get('agent_version') or '-'):<5} {it.get('created_at') or ''}"
727
+ f"{(it.get('agent_version') or '-'):<5} {assistant}{it.get('created_at') or ''}"
669
728
  )
670
729
  typer.echo(f"\n showing {len(items)} of {total} (offset {offset})")
671
730
 
@@ -731,7 +790,10 @@ def _export_trace(
731
790
  trace = (resp.json() or {}).get("data", {})
732
791
  payload = json.dumps(trace, indent=2, ensure_ascii=False)
733
792
  if output:
734
- Path(output).write_text(payload)
793
+ # FDP-4716: `ensure_ascii=False` above means the payload carries the real characters, so
794
+ # the write has to name the encoding — otherwise saving a trace with Vietnamese text
795
+ # raises UnicodeEncodeError on Windows.
796
+ Path(output).write_text(payload, encoding="utf-8")
735
797
  typer.secho(f"✓ wrote trace {trace_id} → {output} ({len(payload)} bytes)", fg=typer.colors.GREEN)
736
798
  return
737
799
  typer.echo(payload)
@@ -1071,7 +1133,10 @@ def threads_history(
1071
1133
  def threads_delete(
1072
1134
  agent: str = typer.Argument(..., help="Deployed agent name."),
1073
1135
  thread_id: str = typer.Argument(..., help="Thread id = the conversation's session_id (its root run_id)."),
1074
- yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt (CI)."),
1136
+ # FDP-4717 (3): `--force` is an alias, not a second flag. `retire`/`delete` took only
1137
+ # `--force` and this took only `--yes`/`-y`, so a CI script had to remember which verb used
1138
+ # which — and `threads delete --force` failed with `No such option`. All three now accept both.
1139
+ yes: bool = typer.Option(False, "--yes", "-y", "--force", help="Skip the confirmation prompt (CI)."),
1075
1140
  api_base: str | None = typer.Option(None, "--api-base", envvar="KEYSTONE_API_BASE"),
1076
1141
  workspace_id: str | None = typer.Option(None, "--workspace-id"),
1077
1142
  ) -> None:
@@ -1287,6 +1352,12 @@ def _warm_up_until_ready(gw_base: str, headers: dict[str, str], agent: str, *, t
1287
1352
  ``502 GW_UPSTREAM_UNAVAILABLE`` past the deadline means it never came up (most often a boot crash
1288
1353
  — CrashLoopBackOff / CreateContainerError). Exits 1 with the reason on timeout."""
1289
1354
  url = f"{gw_base}/api/agent-service/v1/agents/{agent}/runs"
1355
+ # The gateway requires X-Correlation-ID on every proxied request (agent-gateway identity.py); the
1356
+ # edge injects X-Entity-ID/X-User-ID from the JWT but NOT the correlation id — the caller sets it,
1357
+ # like every other data-plane call (invoke / runs). Reusing the publish headers alone omitted it,
1358
+ # so the probe got 400 GW_MISSING_CORRELATION_ID, which the loop below mistook for "still warming"
1359
+ # and waited out to the deadline → a false "did not become Running".
1360
+ headers = {**headers, "X-Correlation-ID": str(uuid.uuid4())}
1290
1361
  deadline = time.monotonic() + timeout_s
1291
1362
  progress = _BuildProgress(sys.stdout.isatty(), label="Warming up ", noun="warm")
1292
1363
  last = "starting"
@@ -1311,7 +1382,20 @@ def _warm_up_until_ready(gw_base: str, headers: dict[str, str], agent: str, *, t
1311
1382
  last = "upstream unavailable"
1312
1383
  progress.tick("upstream unavailable")
1313
1384
  time.sleep(5)
1314
- else: # e.g. route not learned yet right after publish — keep waiting briefly
1385
+ elif resp.status_code == 400:
1386
+ # A rejected request (missing / invalid header) never turns into a 200 by waiting —
1387
+ # fail fast and name it, so it is not mistaken for a boot crash or silently waited out.
1388
+ progress.close()
1389
+ typer.secho(f"✗ warm-up probe rejected: HTTP 400 {resp.text[:200]}", fg=typer.colors.RED)
1390
+ typer.secho(
1391
+ " This is a gateway request-validation error, not a pod boot failure — the "
1392
+ "published version is likely serving. Verify with `keystone agent status` / "
1393
+ "`keystone agent invoke`; if it persists, the agent-service edge is dropping an "
1394
+ "identity header.",
1395
+ fg=typer.colors.RED,
1396
+ )
1397
+ raise typer.Exit(1)
1398
+ else: # e.g. 404 route not learned yet / 403 authz still propagating — keep waiting briefly
1315
1399
  last = f"HTTP {resp.status_code}"
1316
1400
  progress.tick(last)
1317
1401
  time.sleep(5)
@@ -1386,6 +1470,201 @@ def publish(
1386
1470
  raise typer.Exit(1) from exc
1387
1471
 
1388
1472
 
1473
+ #: service declared in `dependencies.services` -> the permission NAMESPACE its grants live under.
1474
+ #: Checked by prefix, not by an exact permission list, ON PURPOSE: the authoritative list is
1475
+ #: AgentManifestAclGrants (Java), and a second copy here would drift the first time someone adds a
1476
+ #: permission. "Is there any memory:* grant?" stays true across that change; "is memory:search
1477
+ #: present?" would not.
1478
+ #: rag's agentic endpoints are gated on this, separately from plain `knowledge-base:query`. It is
1479
+ #: absent from _GRANT_NAMESPACE because rag has no workspace-level grant at all — this one is
1480
+ #: per-KB, and is checked against `dependencies.agentic_knowledge_bases` instead.
1481
+ _KB_QUERY_AGENTIC = "knowledge-base:query:agentic"
1482
+
1483
+ _GRANT_NAMESPACE = {
1484
+ "llm-gateway": "llm:",
1485
+ "guardrails": "guardrails:",
1486
+ "memory": "memory:",
1487
+ "skill": "skill:",
1488
+ }
1489
+
1490
+
1491
+ @agent_app.command()
1492
+ def doctor(
1493
+ agent: str = typer.Argument(..., help="Agent name, e.g. docs-qa."),
1494
+ api_base: str | None = typer.Option(
1495
+ None, "--api-base", envvar="KEYSTONE_API_BASE", help="Platform API base (through Kong)."
1496
+ ),
1497
+ workspace_id: str | None = typer.Option(
1498
+ None, "--workspace-id", help="Target workspace UUID. Defaults to the one set via `keystone workspace use`."
1499
+ ),
1500
+ ) -> None:
1501
+ """Check a deployed agent is actually wired up — grants minted, memory registered.
1502
+
1503
+ Declaring a building block is necessary but not always sufficient, and the gaps only show up
1504
+ as a 403 or 404 on the first real call. This asks the control plane the same questions up
1505
+ front: did agent-hub mint a grant for every service the manifest declares, and (for memory)
1506
+ does the agent have the tenant row that the grant alone does not create.
1507
+ """
1508
+ cfg = ts.load()
1509
+ base = (api_base or cfg.api_base or ts.DEFAULT_API_BASE).rstrip("/")
1510
+ workspace = workspace_id or cfg.workspace_id
1511
+ if not workspace:
1512
+ typer.secho(
1513
+ "✗ no workspace selected — run `keystone workspace use <name|id>` (or pass --workspace-id).",
1514
+ fg=typer.colors.RED,
1515
+ )
1516
+ raise typer.Exit(1)
1517
+ try:
1518
+ token = df.get_access_token(cfg, save=ts.save)
1519
+ except df.DeviceFlowError as exc:
1520
+ typer.secho(f"✗ {exc}", fg=typer.colors.RED)
1521
+ raise typer.Exit(1) from exc
1522
+
1523
+ headers = {"Authorization": f"Bearer {token}", "X-Workspace-ID": workspace}
1524
+ problems = 0
1525
+ try:
1526
+ with httpx.Client(timeout=60) as client:
1527
+ agent_id = _resolve_agent_id(client, base, headers, agent)
1528
+ typer.echo(f"{agent} ({agent_id})")
1529
+
1530
+ deps = _doctor_live_dependencies(client, base, headers, agent_id)
1531
+ if deps is None:
1532
+ typer.secho(" ? no live version — publish one, then re-run.", fg=typer.colors.YELLOW)
1533
+ raise typer.Exit(1)
1534
+ declared = set(_doctor_string_list(deps, "services"))
1535
+ agentic_kbs = _doctor_string_list(deps, "agentic_knowledge_bases")
1536
+ typer.echo(f" declared: {', '.join(sorted(declared)) or '(none)'}")
1537
+
1538
+ entries = _doctor_acl_entries(client, base, headers, agent_id)
1539
+ if entries is not None:
1540
+ problems += _doctor_check_grants(entries, declared)
1541
+ problems += _doctor_check_agentic_kbs(entries, agentic_kbs)
1542
+ if "memory" in declared:
1543
+ problems += _doctor_check_memory(client, base, headers, agent_id, workspace)
1544
+ if "skill" in declared:
1545
+ # Not a failure: a workspace grant is all agent-hub can mint, by decision.
1546
+ typer.secho(
1547
+ " ! skill search/list work from the workspace grant. Content routes "
1548
+ "(get/skill-md/files/read/grep/glob) are checked per-skill — if one 403s, "
1549
+ "grant that skill explicitly. Grants are on-behalf-of, so the invoking "
1550
+ "user's rights count too.",
1551
+ fg=typer.colors.YELLOW,
1552
+ )
1553
+ except httpx.RequestError as exc:
1554
+ typer.secho(f"✗ doctor failed (is {base} reachable / are you on the network?): {exc}", fg=typer.colors.RED)
1555
+ raise typer.Exit(1) from exc
1556
+
1557
+ if problems:
1558
+ typer.secho(f"\n✗ {problems} problem(s) — the agent will fail on those at runtime.", fg=typer.colors.RED)
1559
+ raise typer.Exit(1)
1560
+ typer.secho("\n✓ wiring looks complete", fg=typer.colors.GREEN)
1561
+
1562
+
1563
+ def _doctor_live_dependencies(client: httpx.Client, base: str, headers: dict, agent_id: str) -> dict | None:
1564
+ """`dependencies` of the LIVE version — what the agent is actually running, not your checkout."""
1565
+ resp = client.get(
1566
+ f"{base}/api/agent-hub/v1/agents/{agent_id}/versions",
1567
+ headers=headers,
1568
+ params={"limit": 100, "sort_by": "created_at", "sort_order": "desc"},
1569
+ )
1570
+ if resp.status_code != 200:
1571
+ return None
1572
+ live = next((v for v in resp.json().get("data", []) if v.get("status") == "live"), None)
1573
+ if live is None:
1574
+ return None
1575
+ manifest = live.get("manifest") or {}
1576
+ return manifest.get("dependencies") or {}
1577
+
1578
+
1579
+ def _doctor_string_list(deps: dict, key: str) -> list[str]:
1580
+ return [str(x) for x in (deps.get(key) or []) if isinstance(x, str)]
1581
+
1582
+
1583
+ def _doctor_acl_entries(client: httpx.Client, base: str, headers: dict, agent_id: str) -> list[dict] | None:
1584
+ """The agent's ACL entries, fetched once and shared by every grant check below."""
1585
+ resp = client.get(
1586
+ f"{base}/api/platform/v1/acl-entries",
1587
+ headers=headers,
1588
+ params={"subject_type": "agent", "subject_id": agent_id, "limit": 100},
1589
+ )
1590
+ if resp.status_code != 200:
1591
+ typer.secho(f" ? grants could not read acl-entries ({resp.status_code})", fg=typer.colors.YELLOW)
1592
+ return None
1593
+ return [r for r in resp.json().get("data", []) if isinstance(r, dict)]
1594
+
1595
+
1596
+ def _doctor_check_agentic_kbs(entries: list[dict], declared_agentic: list[str]) -> int:
1597
+ """Did `dependencies.agentic_knowledge_bases` actually mint `knowledge-base:query:agentic`?
1598
+
1599
+ Counted rather than matched by id on purpose: the manifest may declare a KB by NAME, and
1600
+ agent-hub resolves it at publish, so the name in the manifest and the resource_id on the grant
1601
+ are legitimately different strings. A count mismatch is still conclusive enough to be worth
1602
+ reporting — nothing else mints this permission.
1603
+ """
1604
+ if not declared_agentic:
1605
+ return 0
1606
+ granted = [r for r in entries if str(r.get("permission", "")) == _KB_QUERY_AGENTIC]
1607
+ want = len(declared_agentic)
1608
+ if len(granted) >= want:
1609
+ typer.secho(f" ✓ rag {_KB_QUERY_AGENTIC} on {len(granted)} KB(s)", fg=typer.colors.GREEN)
1610
+ return 0
1611
+ if not granted:
1612
+ typer.secho(
1613
+ f" ✗ rag declares {want} agentic KB(s) but holds no {_KB_QUERY_AGENTIC} grant — "
1614
+ "agentic_search/agentic_answer will 403. Check every entry in "
1615
+ "agentic_knowledge_bases also appears in knowledge_bases, spelled identically, "
1616
+ "then re-publish.",
1617
+ fg=typer.colors.RED,
1618
+ )
1619
+ return 1
1620
+ typer.secho(
1621
+ f" ✗ rag declares {want} agentic KB(s) but only {len(granted)} grant(s) landed — "
1622
+ "an entry probably names a KB that knowledge_bases does not, or that does not exist in "
1623
+ "this workspace.",
1624
+ fg=typer.colors.RED,
1625
+ )
1626
+ return 1
1627
+
1628
+
1629
+ def _doctor_check_grants(entries: list[dict], declared: set[str]) -> int:
1630
+ held = {str(r.get("permission", "")) for r in entries}
1631
+ problems = 0
1632
+ for svc in sorted(declared):
1633
+ prefix = _GRANT_NAMESPACE.get(svc)
1634
+ if prefix is None:
1635
+ # rag is the deliberate case: its access IS the per-KB grant, not a service-level one.
1636
+ continue
1637
+ matching = sorted(p for p in held if p.startswith(prefix))
1638
+ if matching:
1639
+ typer.secho(f" ✓ {svc:9} {', '.join(matching)}", fg=typer.colors.GREEN)
1640
+ else:
1641
+ problems += 1
1642
+ typer.secho(
1643
+ f" ✗ {svc:9} no {prefix}* grant — every call 403s. Re-publish so agent-hub mints it.",
1644
+ fg=typer.colors.RED,
1645
+ )
1646
+ return problems
1647
+
1648
+
1649
+ def _doctor_check_memory(client: httpx.Client, base: str, headers: dict, agent_id: str, workspace: str) -> int:
1650
+ """The row memory keeps in its OWN table. A grant does not create it."""
1651
+ resp = client.get(f"{base}/api/memory/v1/agents/{agent_id}/stats", headers=headers)
1652
+ if resp.status_code == 200:
1653
+ typer.secho(" ✓ memory registered in memory's tenant table", fg=typer.colors.GREEN)
1654
+ return 0
1655
+ if resp.status_code == 404:
1656
+ typer.secho(
1657
+ " ✗ memory NOT registered (404 MEM_AGENT_NOT_FOUND) — the grant is not enough. Fix:\n"
1658
+ f" POST {base}/api/memory/v1/workspaces/{workspace}/agents "
1659
+ f'{{"agent_id": "{agent_id}"}}\n'
1660
+ " or set AGENT_HUB_MEMORY_AUTO_PROVISION_ENABLED on agent-hub and re-publish.",
1661
+ fg=typer.colors.RED,
1662
+ )
1663
+ return 1
1664
+ typer.secho(f" ? memory unexpected status {resp.status_code} from stats", fg=typer.colors.YELLOW)
1665
+ return 0
1666
+
1667
+
1389
1668
  @agent_app.command()
1390
1669
  def status(
1391
1670
  agent: str = typer.Argument(..., help="Agent name, e.g. docs-qa."),
@@ -1534,7 +1813,10 @@ def retire(
1534
1813
  workspace_id: str | None = typer.Option(
1535
1814
  None, "--workspace-id", help="Target workspace UUID. Defaults to the one set via `keystone workspace use`."
1536
1815
  ),
1537
- force: bool = typer.Option(False, "--force", help="Skip the open-approvals guard and the confirmation prompt."),
1816
+ # FDP-4717 (3): `--yes`/`-y` accepted as aliases — see `threads delete`.
1817
+ force: bool = typer.Option(
1818
+ False, "--force", "--yes", "-y", help="Skip the open-approvals guard and the confirmation prompt."
1819
+ ),
1538
1820
  ) -> None:
1539
1821
  """Retire ONE version — terminal (FDP-3439 / US-24). A Live version is unpublished first
1540
1822
  (that traffic stops). The operator tears the version's pods/routes down on the retire
@@ -1582,13 +1864,25 @@ def retire(
1582
1864
  @agent_app.command()
1583
1865
  def delete(
1584
1866
  agent: str = typer.Argument(..., help="Agent name to delete ENTIRELY, e.g. rag-qa."),
1867
+ purge_knowledge_bases: bool = typer.Option(
1868
+ False,
1869
+ "--purge-knowledge-bases",
1870
+ help=(
1871
+ "Also delete the knowledge bases this agent created. OFF by default: an agent's KBs "
1872
+ "normally outlive it as ordinary workspace resources, still reachable by anyone with "
1873
+ "the workspace role."
1874
+ ),
1875
+ ),
1585
1876
  api_base: str | None = typer.Option(
1586
1877
  None, "--api-base", envvar="KEYSTONE_API_BASE", help="Platform API base (through Kong)."
1587
1878
  ),
1588
1879
  workspace_id: str | None = typer.Option(
1589
1880
  None, "--workspace-id", help="Target workspace UUID. Defaults to the one set via `keystone workspace use`."
1590
1881
  ),
1591
- force: bool = typer.Option(False, "--force", help="Skip the open-approvals guard and the confirmation prompt."),
1882
+ # FDP-4717 (3): `--yes`/`-y` accepted as aliases — see `threads delete`.
1883
+ force: bool = typer.Option(
1884
+ False, "--force", "--yes", "-y", help="Skip the open-approvals guard and the confirmation prompt."
1885
+ ),
1592
1886
  ) -> None:
1593
1887
  """Delete the WHOLE agent (FDP-3439 / US-24): unpublish Live, retire every staged/live
1594
1888
  version, then soft-delete the registry entry. All traffic stops; pods/routes tear down
@@ -1629,7 +1923,14 @@ def delete(
1629
1923
  if skipped:
1630
1924
  tags = ", ".join(str(v.get("version")) for v in skipped)
1631
1925
  typer.echo(f" skipped non-retirable version(s): {tags} (soft-deleted with the agent)")
1632
- r = client.delete(f"{base}/api/agent-hub/v1/agents/{agent_id}", headers=headers)
1926
+ r = client.delete(
1927
+ f"{base}/api/agent-hub/v1/agents/{agent_id}",
1928
+ headers=headers,
1929
+ # Sent only when asked. agent-hub defaults it to false, so omitting it would work
1930
+ # too — spelling it out keeps the request readable in a proxy log, where "was the
1931
+ # corpus deleted?" is a question someone will eventually have to answer.
1932
+ params={"purge_knowledge_bases": str(purge_knowledge_bases).lower()},
1933
+ )
1633
1934
  if r.status_code in (200, 204, 404): # 404 = already gone (idempotent re-run)
1634
1935
  typer.secho(
1635
1936
  f"✓ deleted {agent} (soft) — pods/routes tear down via the operator; "
@@ -48,6 +48,7 @@ def dev(
48
48
  project_dir=Path(manifest_path).resolve().parent,
49
49
  initial_input=initial,
50
50
  offline=offline,
51
+ config=result.manifest.config,
51
52
  )
52
53
 
53
54