keystone-cli 0.1.0__py3-none-any.whl
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/__init__.py +4 -0
- keystone_cli/__main__.py +24 -0
- keystone_cli/auth/__init__.py +5 -0
- keystone_cli/auth/device_flow.py +197 -0
- keystone_cli/auth/token_store.py +71 -0
- keystone_cli/commands/__init__.py +1 -0
- keystone_cli/commands/agent.py +1017 -0
- keystone_cli/commands/dev.py +57 -0
- keystone_cli/commands/login.py +207 -0
- keystone_cli/commands/workspace.py +87 -0
- keystone_cli/devloop.py +183 -0
- keystone_cli/platform_client.py +75 -0
- keystone_cli/runner.py +87 -0
- keystone_cli/scaffold.py +81 -0
- keystone_cli/templates/blank/README.md.tmpl +25 -0
- keystone_cli/templates/blank/agent.yaml.tmpl +20 -0
- keystone_cli/templates/blank/env.tmpl +10 -0
- keystone_cli/templates/blank/gitignore.tmpl +7 -0
- keystone_cli/templates/blank/pkg/__init__.py.tmpl +0 -0
- keystone_cli/templates/blank/pkg/graph.py.tmpl +33 -0
- keystone_cli/templates/blank/pyproject.toml.tmpl +12 -0
- keystone_cli/templates/hitl/README.md.tmpl +33 -0
- keystone_cli/templates/hitl/agent.yaml.tmpl +33 -0
- keystone_cli/templates/hitl/env.tmpl +10 -0
- keystone_cli/templates/hitl/gitignore.tmpl +7 -0
- keystone_cli/templates/hitl/pkg/__init__.py.tmpl +0 -0
- keystone_cli/templates/hitl/pkg/graph.py.tmpl +115 -0
- keystone_cli/templates/hitl/pyproject.toml.tmpl +12 -0
- keystone_cli/templates/llm/README.md.tmpl +29 -0
- keystone_cli/templates/llm/agent.yaml.tmpl +29 -0
- keystone_cli/templates/llm/env.tmpl +10 -0
- keystone_cli/templates/llm/gitignore.tmpl +7 -0
- keystone_cli/templates/llm/pkg/__init__.py.tmpl +0 -0
- keystone_cli/templates/llm/pkg/graph.py.tmpl +53 -0
- keystone_cli/templates/llm/pyproject.toml.tmpl +12 -0
- keystone_cli/templates/rag-qa/README.md.tmpl +23 -0
- keystone_cli/templates/rag-qa/agent.yaml.tmpl +34 -0
- keystone_cli/templates/rag-qa/env.tmpl +10 -0
- keystone_cli/templates/rag-qa/gitignore.tmpl +7 -0
- keystone_cli/templates/rag-qa/pkg/__init__.py.tmpl +0 -0
- keystone_cli/templates/rag-qa/pkg/graph.py.tmpl +69 -0
- keystone_cli/templates/rag-qa/pyproject.toml.tmpl +12 -0
- keystone_cli/templates/tool-agent/README.md.tmpl +30 -0
- keystone_cli/templates/tool-agent/agent.yaml.tmpl +25 -0
- keystone_cli/templates/tool-agent/env.tmpl +10 -0
- keystone_cli/templates/tool-agent/gitignore.tmpl +7 -0
- keystone_cli/templates/tool-agent/pkg/__init__.py.tmpl +0 -0
- keystone_cli/templates/tool-agent/pkg/graph.py.tmpl +89 -0
- keystone_cli/templates/tool-agent/pyproject.toml.tmpl +15 -0
- keystone_cli-0.1.0.dist-info/METADATA +13 -0
- keystone_cli-0.1.0.dist-info/RECORD +54 -0
- keystone_cli-0.1.0.dist-info/WHEEL +5 -0
- keystone_cli-0.1.0.dist-info/entry_points.txt +2 -0
- keystone_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""``keystone dev`` — the local iterate loop (in-process graph + reload on change).
|
|
2
|
+
|
|
3
|
+
Thin adapter: resolve the manifest, validate it, then hand off to :func:`keystone_cli.devloop.dev_loop`.
|
|
4
|
+
Deliberately a TOP-LEVEL command, not ``keystone agent dev``: it is the command a dev lives in while
|
|
5
|
+
writing the graph, and burying the inner loop one level down makes the common case the long one.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
from keystone.agent_sdk import validate as sdk_validate
|
|
15
|
+
|
|
16
|
+
from keystone_cli.devloop import dev_loop
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def dev(
|
|
20
|
+
path: str = typer.Argument("agent.yaml", help="Path to agent.yaml, or the agent project directory."),
|
|
21
|
+
input_: str = typer.Option("", "--input", help="JSON input for the first run. Omitted → prompt for it."),
|
|
22
|
+
offline: bool = typer.Option(
|
|
23
|
+
False, "--offline", help="Mock rag/gateway calls — iterate with no live services or quota."
|
|
24
|
+
),
|
|
25
|
+
) -> None:
|
|
26
|
+
"""Run the agent's graph in-process, reloading it whenever the source changes."""
|
|
27
|
+
manifest_path = _manifest_path(path)
|
|
28
|
+
result = sdk_validate(manifest_path)
|
|
29
|
+
if not result.ok or result.manifest is None:
|
|
30
|
+
for err in result.errors:
|
|
31
|
+
typer.secho(f"✗ {err}", fg=typer.colors.RED)
|
|
32
|
+
raise typer.Exit(1)
|
|
33
|
+
|
|
34
|
+
initial = None
|
|
35
|
+
if input_.strip():
|
|
36
|
+
try:
|
|
37
|
+
parsed = json.loads(input_)
|
|
38
|
+
except json.JSONDecodeError as exc:
|
|
39
|
+
typer.secho(f"✗ --input is not valid JSON: {exc}", fg=typer.colors.RED)
|
|
40
|
+
raise typer.Exit(1) from exc
|
|
41
|
+
if not isinstance(parsed, dict):
|
|
42
|
+
typer.secho("✗ --input must be a JSON object", fg=typer.colors.RED)
|
|
43
|
+
raise typer.Exit(1)
|
|
44
|
+
initial = parsed
|
|
45
|
+
|
|
46
|
+
dev_loop(
|
|
47
|
+
entrypoint=result.manifest.entrypoint,
|
|
48
|
+
project_dir=Path(manifest_path).resolve().parent,
|
|
49
|
+
initial_input=initial,
|
|
50
|
+
offline=offline,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _manifest_path(path: str) -> str:
|
|
55
|
+
"""Accept either the manifest or its directory — same convention as the `agent` commands."""
|
|
56
|
+
p = Path(path)
|
|
57
|
+
return str(p / "agent.yaml") if p.is_dir() else str(p)
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""``keystone login`` — OAuth2 device flow against Keycloak (FDP-3170).
|
|
2
|
+
|
|
3
|
+
Thin command: run the device flow (``auth/device_flow``), print the verification URI + user code, poll
|
|
4
|
+
until the dev approves in a browser, then cache the tokens (``auth/token_store``, mode 0600). No secret
|
|
5
|
+
is typed on the CLI; only the refresh token is persisted.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import webbrowser
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
import typer
|
|
17
|
+
|
|
18
|
+
from keystone_cli import platform_client as pc
|
|
19
|
+
from keystone_cli.auth import device_flow as df
|
|
20
|
+
from keystone_cli.auth import token_store as ts
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def login(
|
|
24
|
+
issuer: str | None = typer.Option(
|
|
25
|
+
None, "--issuer", envvar="KEYSTONE_ISSUER", help="Keycloak realm URL, e.g. https://<kc>/realms/aip-dev"
|
|
26
|
+
),
|
|
27
|
+
client_id: str | None = typer.Option(
|
|
28
|
+
None, "--client-id", envvar="KEYSTONE_CLIENT_ID", help="Device-flow client id"
|
|
29
|
+
),
|
|
30
|
+
api_base: str | None = typer.Option(
|
|
31
|
+
None, "--api-base", envvar="KEYSTONE_API_BASE", help="Platform API base (through Kong) — used by `deploy`"
|
|
32
|
+
),
|
|
33
|
+
no_browser: bool = typer.Option(
|
|
34
|
+
False, "--no-browser", envvar="KEYSTONE_NO_BROWSER", help="Don't auto-open the browser (SSH / headless)"
|
|
35
|
+
),
|
|
36
|
+
no_select_workspace: bool = typer.Option(
|
|
37
|
+
False, "--no-select-workspace", help="Skip the post-login default-workspace picker (scripts/CI)."
|
|
38
|
+
),
|
|
39
|
+
client_credentials: bool = typer.Option(
|
|
40
|
+
False,
|
|
41
|
+
"--client-credentials",
|
|
42
|
+
help=(
|
|
43
|
+
f"Non-interactive service-account login for CI. Reads the secret from ${df.ENV_CLIENT_SECRET} "
|
|
44
|
+
f"only — there is deliberately no --client-secret flag, because a secret on argv is visible in "
|
|
45
|
+
f"`ps`, shell history and CI build logs."
|
|
46
|
+
),
|
|
47
|
+
),
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Log in via the Keycloak device flow and cache the refresh token (~/.keystone/config.json, 0600)."""
|
|
50
|
+
# Precedence: --flag / KEYSTONE_* env (typer) > cached config > baked platform default → `keystone
|
|
51
|
+
# login` works flagless once the baked issuer is set (Ops); flags are only for other envs / overrides.
|
|
52
|
+
cfg = ts.load()
|
|
53
|
+
cfg.issuer = issuer or cfg.issuer or ts.DEFAULT_ISSUER
|
|
54
|
+
cfg.client_id = client_id or cfg.client_id or ts.DEFAULT_CLIENT_ID
|
|
55
|
+
cfg.api_base = api_base or cfg.api_base or ts.DEFAULT_API_BASE
|
|
56
|
+
if not cfg.issuer or not cfg.client_id:
|
|
57
|
+
typer.secho(
|
|
58
|
+
"✗ no issuer configured — pass --issuer (or set KEYSTONE_ISSUER), or bake DEFAULT_ISSUER once Ops "
|
|
59
|
+
"provisions the Keycloak client",
|
|
60
|
+
fg=typer.colors.RED,
|
|
61
|
+
)
|
|
62
|
+
raise typer.Exit(1)
|
|
63
|
+
|
|
64
|
+
# A terminal is what makes the device grant work — a human reads a code and approves in a browser.
|
|
65
|
+
# Without one, opening a browser is pointless (and on a runner it can hang), so it is suppressed.
|
|
66
|
+
interactive = sys.stdin.isatty() and sys.stdout.isatty()
|
|
67
|
+
if not interactive:
|
|
68
|
+
no_browser = True
|
|
69
|
+
|
|
70
|
+
if client_credentials:
|
|
71
|
+
_login_client_credentials(cfg, interactive=interactive, no_select_workspace=no_select_workspace)
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
if not interactive:
|
|
75
|
+
typer.secho(
|
|
76
|
+
" (no TTY — not opening a browser; the device grant still needs a human to approve the URL below. "
|
|
77
|
+
f"For unattended runs use --client-credentials or set ${df.ENV_TOKEN}.)",
|
|
78
|
+
fg=typer.colors.YELLOW,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
with httpx.Client(timeout=30) as client:
|
|
82
|
+
try:
|
|
83
|
+
start = df.start_device_flow(client, cfg.issuer, cfg.client_id)
|
|
84
|
+
except df.DeviceFlowError as exc:
|
|
85
|
+
typer.secho(f"✗ {exc}", fg=typer.colors.RED)
|
|
86
|
+
raise typer.Exit(1) from exc
|
|
87
|
+
|
|
88
|
+
# verification_uri_complete has the code pre-embedded (AWS-CLI-style: click → Allow, no typing).
|
|
89
|
+
verify_uri = start.get("verification_uri_complete") or start.get("verification_uri", "")
|
|
90
|
+
user_code = start.get("user_code", "")
|
|
91
|
+
typer.secho("\nTo authorize this CLI, open:", fg=typer.colors.CYAN)
|
|
92
|
+
typer.echo(f" {verify_uri}")
|
|
93
|
+
typer.secho(f" (code: {user_code})", fg=typer.colors.YELLOW)
|
|
94
|
+
# Auto-open the browser to the code-embedded URL (best-effort; the URL above is the fallback).
|
|
95
|
+
# contextlib.suppress, not try/except/pass — headless/no-DISPLAY raises and is fine (bandit B110).
|
|
96
|
+
if not no_browser:
|
|
97
|
+
with contextlib.suppress(Exception):
|
|
98
|
+
webbrowser.open(verify_uri)
|
|
99
|
+
typer.echo("\nWaiting for approval…")
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
token = df.poll_for_token(
|
|
103
|
+
client,
|
|
104
|
+
cfg.issuer,
|
|
105
|
+
cfg.client_id,
|
|
106
|
+
start["device_code"],
|
|
107
|
+
interval=int(start.get("interval", 5)),
|
|
108
|
+
expires_in=int(start.get("expires_in", 600)),
|
|
109
|
+
)
|
|
110
|
+
except df.DeviceFlowError as exc:
|
|
111
|
+
typer.secho(f"✗ {exc}", fg=typer.colors.RED)
|
|
112
|
+
raise typer.Exit(1) from exc
|
|
113
|
+
|
|
114
|
+
df.apply_token_response(cfg, token)
|
|
115
|
+
ts.save(cfg)
|
|
116
|
+
typer.secho(f"✓ logged in — token cached at {ts.config_path()}", fg=typer.colors.GREEN)
|
|
117
|
+
|
|
118
|
+
if not no_select_workspace:
|
|
119
|
+
# No TTY gate here on purpose: the picker already degrades to "skipped" on EOF, and gating it
|
|
120
|
+
# would change behaviour for piped-but-answered input, which nobody asked for.
|
|
121
|
+
_pick_default_workspace(cfg)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _login_client_credentials(cfg: ts.AuthConfig, *, interactive: bool, no_select_workspace: bool) -> None:
|
|
125
|
+
"""Service-account login: mint once, record the mode, done. No browser, no prompt, no refresh token.
|
|
126
|
+
|
|
127
|
+
``auth_mode`` is persisted so the read path knows to re-run this grant when the token expires
|
|
128
|
+
rather than looking for a refresh token that will never exist.
|
|
129
|
+
"""
|
|
130
|
+
secret = (os.environ.get(df.ENV_CLIENT_SECRET) or "").strip()
|
|
131
|
+
if not secret:
|
|
132
|
+
typer.secho(
|
|
133
|
+
f"✗ --client-credentials needs ${df.ENV_CLIENT_SECRET} in the environment "
|
|
134
|
+
f"(there is no --client-secret flag on purpose — argv leaks).",
|
|
135
|
+
fg=typer.colors.RED,
|
|
136
|
+
)
|
|
137
|
+
raise typer.Exit(1)
|
|
138
|
+
|
|
139
|
+
with httpx.Client(timeout=30) as client:
|
|
140
|
+
try:
|
|
141
|
+
token = df.client_credentials_token(client, cfg.issuer, cfg.client_id, secret)
|
|
142
|
+
except df.DeviceFlowError as exc:
|
|
143
|
+
typer.secho(f"✗ {exc}", fg=typer.colors.RED)
|
|
144
|
+
raise typer.Exit(1) from exc
|
|
145
|
+
|
|
146
|
+
cfg.auth_mode = df.MODE_CLIENT_CREDENTIALS
|
|
147
|
+
# The grant issues no refresh token; clear any stale one from an earlier human login on this
|
|
148
|
+
# machine. Bandit B105 fires on the empty literal alone — and it parses everything after
|
|
149
|
+
# `nosec` as test ids, so the reason has to live up here rather than trailing the pragma.
|
|
150
|
+
cfg.refresh_token = "" # nosec B105
|
|
151
|
+
df.apply_token_response(cfg, token)
|
|
152
|
+
ts.save(cfg)
|
|
153
|
+
typer.secho(
|
|
154
|
+
f"✓ logged in as service account '{cfg.client_id}' — config at {ts.config_path()}",
|
|
155
|
+
fg=typer.colors.GREEN,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
if not no_select_workspace and interactive:
|
|
159
|
+
_pick_default_workspace(cfg)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _pick_default_workspace(cfg: ts.AuthConfig) -> None:
|
|
163
|
+
"""Best-effort post-login step: pick a default workspace so `deploy` needs no extra step.
|
|
164
|
+
|
|
165
|
+
A user belongs to an entity (from the JWT) but must choose a workspace per deploy. Fetch the
|
|
166
|
+
access tree (`GET /users/me`), then: 0 → nothing to pick; 1 → auto-select; many → prompt. Never
|
|
167
|
+
fails the login — a platform hiccup just prints a hint to run `keystone workspace use` later.
|
|
168
|
+
"""
|
|
169
|
+
base = (cfg.api_base or ts.DEFAULT_API_BASE).rstrip("/")
|
|
170
|
+
try:
|
|
171
|
+
me = pc.get_me(cfg.access_token, base)
|
|
172
|
+
except pc.PlatformError as exc:
|
|
173
|
+
typer.secho(
|
|
174
|
+
f" (couldn't list workspaces: {exc} — set one later with `keystone workspace use`)",
|
|
175
|
+
fg=typer.colors.YELLOW,
|
|
176
|
+
)
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
rows = pc.workspace_rows(me)
|
|
180
|
+
if not rows:
|
|
181
|
+
typer.echo(" (no workspaces available for your account yet)")
|
|
182
|
+
return
|
|
183
|
+
if len(rows) == 1:
|
|
184
|
+
_save_workspace(cfg, rows[0])
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
typer.echo("\nYour workspaces:")
|
|
188
|
+
for i, r in enumerate(rows, 1):
|
|
189
|
+
current = " (current)" if r.workspace_id == cfg.workspace_id else ""
|
|
190
|
+
typer.echo(f" {i}) {r.entity}/{r.workspace}{current}")
|
|
191
|
+
try:
|
|
192
|
+
choice = typer.prompt(
|
|
193
|
+
"Select a default workspace [number, Enter to skip]", default="", show_default=False
|
|
194
|
+
).strip()
|
|
195
|
+
except typer.Abort: # Ctrl-C / EOF (non-interactive) → treat as skip; login already succeeded.
|
|
196
|
+
choice = ""
|
|
197
|
+
if not choice.isdigit() or not (1 <= int(choice) <= len(rows)):
|
|
198
|
+
typer.echo(" (skipped — set later with `keystone workspace use <name|id>`)")
|
|
199
|
+
return
|
|
200
|
+
_save_workspace(cfg, rows[int(choice) - 1])
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _save_workspace(cfg: ts.AuthConfig, row: pc.WorkspaceRow) -> None:
|
|
204
|
+
cfg.workspace_id = row.workspace_id
|
|
205
|
+
cfg.workspace_name = row.workspace
|
|
206
|
+
ts.save(cfg)
|
|
207
|
+
typer.secho(f"✓ default workspace: {row.entity}/{row.workspace}", fg=typer.colors.GREEN)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""``keystone workspace`` — discover + select the workspace that ``deploy`` targets (FDP-3170).
|
|
2
|
+
|
|
3
|
+
A user belongs to an entity (from the JWT); the workspace is a per-session choice. `list` shows the
|
|
4
|
+
entity→workspace tree from ``GET /users/me``; `use` saves the chosen workspace UUID into
|
|
5
|
+
``~/.keystone/config.json`` (sent as X-Workspace-ID by `deploy`); `current` shows the selection.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from keystone_cli import platform_client as pc
|
|
13
|
+
from keystone_cli.auth import device_flow as df
|
|
14
|
+
from keystone_cli.auth import token_store as ts
|
|
15
|
+
|
|
16
|
+
workspace_app = typer.Typer(
|
|
17
|
+
help="Discover + select the workspace that `agent deploy` targets.",
|
|
18
|
+
no_args_is_help=True,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _fetch_rows() -> tuple[ts.AuthConfig, list[pc.WorkspaceRow]]:
|
|
23
|
+
"""Load config, get a token (cached/refreshed), and return the caller's workspace rows."""
|
|
24
|
+
cfg = ts.load()
|
|
25
|
+
base = (cfg.api_base or ts.DEFAULT_API_BASE).rstrip("/")
|
|
26
|
+
try:
|
|
27
|
+
token = df.get_access_token(cfg, save=ts.save)
|
|
28
|
+
except df.DeviceFlowError as exc:
|
|
29
|
+
typer.secho(f"✗ {exc} — run `keystone login` first.", fg=typer.colors.RED)
|
|
30
|
+
raise typer.Exit(1) from exc
|
|
31
|
+
try:
|
|
32
|
+
me = pc.get_me(token, base)
|
|
33
|
+
except pc.PlatformError as exc:
|
|
34
|
+
typer.secho(f"✗ {exc}", fg=typer.colors.RED)
|
|
35
|
+
raise typer.Exit(1) from exc
|
|
36
|
+
return cfg, pc.workspace_rows(me)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@workspace_app.command("list")
|
|
40
|
+
def list_workspaces() -> None:
|
|
41
|
+
"""List every workspace your account can reach (entity / workspace / id)."""
|
|
42
|
+
cfg, rows = _fetch_rows()
|
|
43
|
+
if not rows:
|
|
44
|
+
typer.echo("No workspaces available for your account.")
|
|
45
|
+
return
|
|
46
|
+
ew = max(len("ENTITY"), *(len(r.entity) for r in rows))
|
|
47
|
+
ww = max(len("WORKSPACE"), *(len(r.workspace) for r in rows))
|
|
48
|
+
typer.echo(f" {'ENTITY':<{ew}} {'WORKSPACE':<{ww}} ID")
|
|
49
|
+
for r in rows:
|
|
50
|
+
mark = "*" if r.workspace_id and r.workspace_id == cfg.workspace_id else " "
|
|
51
|
+
typer.echo(f"{mark} {r.entity:<{ew}} {r.workspace:<{ww}} {r.workspace_id}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@workspace_app.command()
|
|
55
|
+
def use(ref: str = typer.Argument(..., help="Workspace name or UUID.")) -> None:
|
|
56
|
+
"""Select a workspace by name or UUID — saved for subsequent `agent deploy`."""
|
|
57
|
+
cfg, rows = _fetch_rows()
|
|
58
|
+
matches = [r for r in rows if r.workspace_id == ref] or [r for r in rows if r.workspace.lower() == ref.lower()]
|
|
59
|
+
if not matches:
|
|
60
|
+
typer.secho(
|
|
61
|
+
f"✗ no workspace '{ref}' in your access tree — run `keystone workspace list`.",
|
|
62
|
+
fg=typer.colors.RED,
|
|
63
|
+
)
|
|
64
|
+
raise typer.Exit(1)
|
|
65
|
+
if len(matches) > 1:
|
|
66
|
+
typer.secho(f"✗ '{ref}' matches multiple workspaces — pick by ID:", fg=typer.colors.RED)
|
|
67
|
+
for r in matches:
|
|
68
|
+
typer.echo(f" {r.entity}/{r.workspace} {r.workspace_id}")
|
|
69
|
+
raise typer.Exit(1)
|
|
70
|
+
row = matches[0]
|
|
71
|
+
cfg.workspace_id = row.workspace_id
|
|
72
|
+
cfg.workspace_name = row.workspace
|
|
73
|
+
ts.save(cfg)
|
|
74
|
+
typer.secho(
|
|
75
|
+
f"✓ workspace set: {row.entity}/{row.workspace} ({row.workspace_id})",
|
|
76
|
+
fg=typer.colors.GREEN,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@workspace_app.command()
|
|
81
|
+
def current() -> None:
|
|
82
|
+
"""Show the currently-selected workspace."""
|
|
83
|
+
cfg = ts.load()
|
|
84
|
+
if not cfg.workspace_id:
|
|
85
|
+
typer.echo("No workspace selected. Run `keystone workspace use <name|id>`.")
|
|
86
|
+
return
|
|
87
|
+
typer.echo(f"{cfg.workspace_name or '?'} ({cfg.workspace_id})")
|
keystone_cli/devloop.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""The `keystone dev` loop: run the agent's graph IN-PROCESS, reloading it when the source changes.
|
|
2
|
+
|
|
3
|
+
"Deploy local" here means in-process — no container, no Postgres/Redis, no Celery. The graph is
|
|
4
|
+
imported from the project directory exactly the way ``keystone agent run`` imports it, so what you
|
|
5
|
+
iterate on locally is the same object the runtime compiles.
|
|
6
|
+
|
|
7
|
+
The whole reason this module exists rather than a shell loop around ``keystone agent run``:
|
|
8
|
+
|
|
9
|
+
* **``sys.modules`` caches the agent's package.** A second ``load_graph`` in the same process
|
|
10
|
+
re-imports nothing — it hands back the module object built from the code as it was at first
|
|
11
|
+
import. A dev-loop that ignores this edits ``graph.py``, sees the OLD behaviour, and gets no
|
|
12
|
+
warning at all; the natural conclusion is "my change did nothing", which is the worst possible
|
|
13
|
+
feedback from a tool whose entire job is feedback. :func:`_purge_project_modules` is the fix, and
|
|
14
|
+
it is scoped by ``__file__`` living under the project root so third-party packages (langgraph,
|
|
15
|
+
httpx) are NOT reloaded — reloading those would be slow and, worse, would give the graph a second
|
|
16
|
+
copy of classes it compares against with ``isinstance``.
|
|
17
|
+
* **Reload has to be cheap enough to be automatic.** Change detection is an mtime+size scan of the
|
|
18
|
+
project's ``*.py``, checked right before each run, so no thread and no watchdog dependency. The
|
|
19
|
+
cost is that a change made *while* a run is in flight is picked up on the next one, which is the
|
|
20
|
+
right trade for a REPL.
|
|
21
|
+
|
|
22
|
+
One input per Enter; empty input re-uses the previous one, so the edit → Enter → read-trace loop
|
|
23
|
+
needs no retyping. Errors never end the session: a graph that fails to import or raises mid-run
|
|
24
|
+
prints and returns you to the prompt, because the next thing you do is fix it and press Enter.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import sys
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import TYPE_CHECKING, Any
|
|
34
|
+
|
|
35
|
+
import typer
|
|
36
|
+
from keystone.agent_sdk.loader import GraphLoadError
|
|
37
|
+
|
|
38
|
+
from keystone_cli.runner import run_agent
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from collections.abc import Callable
|
|
42
|
+
|
|
43
|
+
# Watched for changes. `agent.yaml` is included because the entrypoint can move — but note the
|
|
44
|
+
# manifest is re-read only to detect the change; a manifest edit reloads the graph, it does not
|
|
45
|
+
# re-validate the whole file (run `keystone agent validate` for that).
|
|
46
|
+
_WATCH_GLOBS = ("*.py", "agent.yaml")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class _Fingerprint:
|
|
51
|
+
"""Cheap change signal: (path, mtime_ns, size) for every watched file."""
|
|
52
|
+
|
|
53
|
+
entries: frozenset[tuple[str, int, int]]
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def of(cls, project_dir: Path) -> _Fingerprint:
|
|
57
|
+
out: set[tuple[str, int, int]] = set()
|
|
58
|
+
for pattern in _WATCH_GLOBS:
|
|
59
|
+
for path in project_dir.rglob(pattern):
|
|
60
|
+
# A venv inside the project would swamp the scan and never be the thing you edited.
|
|
61
|
+
if not path.is_file() or _is_noise(path.relative_to(project_dir)):
|
|
62
|
+
continue
|
|
63
|
+
try:
|
|
64
|
+
st = path.stat()
|
|
65
|
+
except OSError: # deleted between glob and stat — treat as a change
|
|
66
|
+
continue
|
|
67
|
+
out.add((str(path), st.st_mtime_ns, st.st_size))
|
|
68
|
+
return cls(entries=frozenset(out))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
_NOISE_DIRS = frozenset({".venv", "venv", "env", "__pycache__", ".git", "build", "dist", ".keystone"})
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _is_noise(rel: Path) -> bool:
|
|
75
|
+
return bool(set(rel.parts) & _NOISE_DIRS)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _purge_project_modules(project_dir: Path) -> list[str]:
|
|
79
|
+
"""Drop every already-imported module whose file lives under ``project_dir``.
|
|
80
|
+
|
|
81
|
+
Returns the purged names (for the "reloaded" line). Scoped by ``__file__`` rather than by name
|
|
82
|
+
prefix on purpose: the agent's package name is not knowable from the entrypoint alone once it
|
|
83
|
+
imports siblings, and a name-prefix rule would either miss those or over-match a third-party
|
|
84
|
+
package that happens to share the prefix.
|
|
85
|
+
"""
|
|
86
|
+
root = str(project_dir.resolve())
|
|
87
|
+
purged = [
|
|
88
|
+
name
|
|
89
|
+
for name, module in list(sys.modules.items())
|
|
90
|
+
if (file := getattr(module, "__file__", None)) and str(Path(file).resolve()).startswith(root + "/")
|
|
91
|
+
]
|
|
92
|
+
for name in purged:
|
|
93
|
+
del sys.modules[name]
|
|
94
|
+
return purged
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _parse_input(raw: str) -> dict[str, Any]:
|
|
98
|
+
value = json.loads(raw)
|
|
99
|
+
if not isinstance(value, dict):
|
|
100
|
+
raise ValueError(f"input must be a JSON object, got {type(value).__name__}")
|
|
101
|
+
return value
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def dev_loop(
|
|
105
|
+
*,
|
|
106
|
+
entrypoint: str,
|
|
107
|
+
project_dir: Path,
|
|
108
|
+
initial_input: dict[str, Any] | None,
|
|
109
|
+
offline: bool,
|
|
110
|
+
read_line: Callable[[], str] | None = None,
|
|
111
|
+
) -> None:
|
|
112
|
+
"""Run the interactive loop until EOF/quit. ``read_line`` is injected so tests can drive it."""
|
|
113
|
+
prompt = read_line or (lambda: typer.prompt("input", default="", show_default=False))
|
|
114
|
+
|
|
115
|
+
def _next_line() -> str | None:
|
|
116
|
+
"""The dev's next line, or ``None`` when the session is over.
|
|
117
|
+
|
|
118
|
+
Ctrl-D / a closed stdin is the DOCUMENTED way to quit, so it must exit 0 and print nothing.
|
|
119
|
+
``typer.prompt`` raises ``typer.Abort`` there — not ``EOFError`` — and letting that escape
|
|
120
|
+
makes typer print "Aborted." and exit **1**, i.e. quitting as instructed looks like a
|
|
121
|
+
failure and would fail any script or CI step wrapping the command. Caught via ``typer.Abort``
|
|
122
|
+
and NOT ``click.Abort``: typer 0.27 vendors click as ``typer._click``, so ``import click``
|
|
123
|
+
raises ModuleNotFoundError here — and click is not a declared dependency of this package
|
|
124
|
+
either, so importing it directly would be relying on someone else's transitive dep.
|
|
125
|
+
"""
|
|
126
|
+
try:
|
|
127
|
+
raw = prompt()
|
|
128
|
+
except (EOFError, KeyboardInterrupt, typer.Abort):
|
|
129
|
+
typer.echo("")
|
|
130
|
+
return None
|
|
131
|
+
return None if raw.strip() in {"quit", "exit"} else raw
|
|
132
|
+
|
|
133
|
+
fingerprint = _Fingerprint.of(project_dir)
|
|
134
|
+
last_input: dict[str, Any] | None = initial_input
|
|
135
|
+
|
|
136
|
+
typer.secho(
|
|
137
|
+
f"keystone dev — {entrypoint} (in-process{', offline' if offline else ''}). "
|
|
138
|
+
"Enter JSON input, blank to repeat the last one, Ctrl-D to quit.",
|
|
139
|
+
fg=typer.colors.GREEN,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
while True:
|
|
143
|
+
current = _Fingerprint.of(project_dir)
|
|
144
|
+
if current != fingerprint:
|
|
145
|
+
purged = _purge_project_modules(project_dir)
|
|
146
|
+
fingerprint = current
|
|
147
|
+
typer.secho(f"↻ source changed — reloaded {len(purged)} module(s)", fg=typer.colors.MAGENTA)
|
|
148
|
+
|
|
149
|
+
if last_input is None:
|
|
150
|
+
raw = _next_line()
|
|
151
|
+
if raw is None:
|
|
152
|
+
return
|
|
153
|
+
if not raw.strip():
|
|
154
|
+
typer.secho("• nothing to run yet — paste a JSON object", fg=typer.colors.YELLOW)
|
|
155
|
+
continue
|
|
156
|
+
try:
|
|
157
|
+
last_input = _parse_input(raw)
|
|
158
|
+
except (ValueError, json.JSONDecodeError) as exc:
|
|
159
|
+
typer.secho(f"✗ bad input: {exc}", fg=typer.colors.RED)
|
|
160
|
+
continue
|
|
161
|
+
|
|
162
|
+
_run_once(entrypoint=entrypoint, project_dir=project_dir, inputs=last_input, offline=offline)
|
|
163
|
+
|
|
164
|
+
raw = _next_line()
|
|
165
|
+
if raw is None:
|
|
166
|
+
return
|
|
167
|
+
if raw.strip():
|
|
168
|
+
try:
|
|
169
|
+
last_input = _parse_input(raw)
|
|
170
|
+
except (ValueError, json.JSONDecodeError) as exc:
|
|
171
|
+
typer.secho(f"✗ bad input: {exc}", fg=typer.colors.RED)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _run_once(*, entrypoint: str, project_dir: Path, inputs: dict[str, Any], offline: bool) -> None:
|
|
175
|
+
"""One run. Never raises — a broken graph must return you to the prompt, not end the session."""
|
|
176
|
+
try:
|
|
177
|
+
state = run_agent(entrypoint=entrypoint, project_dir=project_dir, inputs=inputs, offline=offline)
|
|
178
|
+
except GraphLoadError as exc:
|
|
179
|
+
typer.secho(f"✗ {exc}", fg=typer.colors.RED)
|
|
180
|
+
except Exception as exc: # noqa: BLE001 — the agent's own code raised; show it and keep going
|
|
181
|
+
typer.secho(f"✗ run failed: {type(exc).__name__}: {exc}", fg=typer.colors.RED)
|
|
182
|
+
else:
|
|
183
|
+
typer.secho(f"✓ result: {state}", fg=typer.colors.GREEN)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Thin platform API client for the CLI (FDP-3170).
|
|
2
|
+
|
|
3
|
+
Currently just ``GET /api/platform/v1/users/me`` — the caller's entity→workspace access tree,
|
|
4
|
+
used to discover + select the workspace that ``deploy`` targets. ``/users/me`` is on platform's
|
|
5
|
+
tenancy bypass list and is keyed on the JWT subject, so it needs only the login bearer token (the
|
|
6
|
+
edge injects X-User-ID / X-Entity-ID); no X-Workspace-ID (that's the thing we're trying to find).
|
|
7
|
+
|
|
8
|
+
FDP-3482 — platform retired the Stage tier: ``/users/me`` now returns workspaces directly under
|
|
9
|
+
each entity (``entities[].workspaces[]``), a two-level tree. We still read the legacy three-level
|
|
10
|
+
shape (``entities[].stages[].workspaces[]``) as a fallback so the CLI keeps working against any
|
|
11
|
+
env that hasn't deployed FDP-3482 yet (dev leads qc/staging).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import NamedTuple
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
ME_PATH = "/api/platform/v1/users/me"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PlatformError(Exception):
|
|
24
|
+
"""Platform call failed (transport error or non-200)."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class WorkspaceRow(NamedTuple):
|
|
28
|
+
"""One leaf of the /users/me access tree. ``entity``/``workspace`` are display names.
|
|
29
|
+
|
|
30
|
+
FDP-2071: the Stage tier is retired fleet-wide — rows no longer carry stage fields."""
|
|
31
|
+
|
|
32
|
+
entity: str
|
|
33
|
+
workspace: str
|
|
34
|
+
workspace_id: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_me(token: str, api_base: str, *, client: httpx.Client | None = None) -> dict:
|
|
38
|
+
"""Return the ``data`` of ``GET /users/me`` — ``{user, entities:[{..,workspaces:[]}]}`` post-FDP-3482
|
|
39
|
+
(legacy envs may still nest ``workspaces`` under a ``stages`` level; ``workspace_rows`` reads both).
|
|
40
|
+
|
|
41
|
+
``client`` is injectable for tests; otherwise a short-lived client is used.
|
|
42
|
+
"""
|
|
43
|
+
url = f"{api_base.rstrip('/')}{ME_PATH}"
|
|
44
|
+
headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
|
|
45
|
+
try:
|
|
46
|
+
if client is not None:
|
|
47
|
+
resp = client.get(url, headers=headers)
|
|
48
|
+
else:
|
|
49
|
+
with httpx.Client(timeout=30) as owned:
|
|
50
|
+
resp = owned.get(url, headers=headers)
|
|
51
|
+
except httpx.RequestError as exc:
|
|
52
|
+
raise PlatformError(f"could not reach platform at {api_base} ({exc})") from exc
|
|
53
|
+
|
|
54
|
+
if resp.status_code != 200:
|
|
55
|
+
raise PlatformError(f"platform {ME_PATH} returned {resp.status_code}: {resp.text[:200]}")
|
|
56
|
+
body = resp.json() if resp.content else {}
|
|
57
|
+
return body.get("data", {})
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def workspace_rows(me: dict) -> list[WorkspaceRow]:
|
|
61
|
+
"""Flatten the ``/me`` tree to ``WorkspaceRow(entity, workspace, workspace_id)``.
|
|
62
|
+
|
|
63
|
+
FDP-2071 removed the Stage tier fleet-wide; the tree is two-level (``entity.workspaces``).
|
|
64
|
+
The legacy three-level nesting (``entity.stages[].workspaces``) is still READ so the CLI keeps
|
|
65
|
+
listing workspaces against any straggler environment — the stage values themselves are gone."""
|
|
66
|
+
rows: list[WorkspaceRow] = []
|
|
67
|
+
for entity in me.get("entities", []) or []:
|
|
68
|
+
ename = entity.get("name", "")
|
|
69
|
+
for ws in entity.get("workspaces", []) or []:
|
|
70
|
+
rows.append(WorkspaceRow(ename, ws.get("name", ""), ws.get("id", "")))
|
|
71
|
+
# Legacy nesting: read the workspaces, discard the retired stage tier.
|
|
72
|
+
for stage in entity.get("stages", []) or []:
|
|
73
|
+
for ws in stage.get("workspaces", []) or []:
|
|
74
|
+
rows.append(WorkspaceRow(ename, ws.get("name", ""), ws.get("id", "")))
|
|
75
|
+
return rows
|