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
keystone_cli/__init__.py
ADDED
keystone_cli/__main__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""``keystone`` CLI entry-point (FDP-3120). ``[project.scripts] keystone = …:app``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from keystone_cli.commands.agent import agent_app
|
|
8
|
+
from keystone_cli.commands.dev import dev
|
|
9
|
+
from keystone_cli.commands.login import login
|
|
10
|
+
from keystone_cli.commands.workspace import workspace_app
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(
|
|
13
|
+
help="Keystone pro-code agent CLI — author, validate, and run agents locally.",
|
|
14
|
+
no_args_is_help=True,
|
|
15
|
+
add_completion=False,
|
|
16
|
+
)
|
|
17
|
+
app.add_typer(agent_app, name="agent")
|
|
18
|
+
app.add_typer(workspace_app, name="workspace")
|
|
19
|
+
app.command(name="login")(login)
|
|
20
|
+
app.command(name="dev")(dev)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
if __name__ == "__main__":
|
|
24
|
+
app()
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
"""CLI auth — OAuth2 device flow (Keycloak) + the ~/.keystone/config.json token store (FDP-3170).
|
|
2
|
+
|
|
3
|
+
`login` runs the device flow and caches the refresh token; `deploy` reads a valid access token
|
|
4
|
+
(auto-refreshing) from the same store. No long-lived secret beyond the refresh token, file mode 0600.
|
|
5
|
+
"""
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Token acquisition for the CLI — device grant for humans, client credentials for CI (FDP-3170/3441).
|
|
2
|
+
|
|
3
|
+
Pure-ish transport logic (takes an ``httpx.Client`` + injectable ``sleep``/``now``/``env`` for tests).
|
|
4
|
+
The Typer command in ``commands/login`` handles printing + storing. :func:`get_access_token` is the
|
|
5
|
+
read path every command uses, and it knows three ways to produce a bearer:
|
|
6
|
+
|
|
7
|
+
1. ``KEYSTONE_TOKEN`` in the environment — used verbatim, never refreshed, never written to disk.
|
|
8
|
+
The CI escape hatch for when something else already minted a token.
|
|
9
|
+
2. the cached access token, while it is still fresh;
|
|
10
|
+
3. renewal — refresh-token grant for ``device`` mode, or a fresh client-credentials grant for
|
|
11
|
+
``client_credentials`` mode (service accounts get no refresh token).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import time
|
|
18
|
+
from collections.abc import Callable, Mapping
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
from keystone_cli.auth.token_store import AuthConfig
|
|
24
|
+
|
|
25
|
+
_DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"
|
|
26
|
+
_EXPIRY_SKEW = 30.0 # refresh this many seconds before the access token actually expires
|
|
27
|
+
|
|
28
|
+
# Both names below are env var NAMES, never values — but three scanners see `TOKEN`/`SECRET` next to
|
|
29
|
+
# a string literal and each wants its own suppression: ruff `noqa: S105`, bandit `nosec B105` (CI
|
|
30
|
+
# only, not in pre-commit), detect-secrets `pragma`. Renaming to dodge them would cost the one thing
|
|
31
|
+
# these constants have going for them, which is saying exactly what they are.
|
|
32
|
+
#
|
|
33
|
+
# Pre-minted bearer, used as-is. Named so CI can inject a token without a login step.
|
|
34
|
+
ENV_TOKEN = "KEYSTONE_TOKEN" # noqa: S105 # nosec B105
|
|
35
|
+
# The client-credentials secret is read ONLY from the environment, never from a flag: a value on
|
|
36
|
+
# argv is visible in `ps`, lands in shell history, and CI runners echo command lines into build logs.
|
|
37
|
+
ENV_CLIENT_SECRET = "KEYSTONE_CLIENT_SECRET" # noqa: S105 # pragma: allowlist secret # nosec B105
|
|
38
|
+
|
|
39
|
+
MODE_DEVICE = "device"
|
|
40
|
+
MODE_CLIENT_CREDENTIALS = "client_credentials"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class DeviceFlowError(Exception):
|
|
44
|
+
"""Device-flow / token exchange failed (denied, expired, or transport)."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _device_endpoint(issuer: str) -> str:
|
|
48
|
+
return f"{issuer.rstrip('/')}/protocol/openid-connect/auth/device"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _token_endpoint(issuer: str) -> str:
|
|
52
|
+
return f"{issuer.rstrip('/')}/protocol/openid-connect/token"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def start_device_flow(client: httpx.Client, issuer: str, client_id: str) -> dict[str, Any]:
|
|
56
|
+
"""Kick off the flow → device_code, user_code, verification_uri[_complete], interval, expires_in."""
|
|
57
|
+
resp = client.post(
|
|
58
|
+
_device_endpoint(issuer),
|
|
59
|
+
data={"client_id": client_id, "scope": "openid offline_access"},
|
|
60
|
+
)
|
|
61
|
+
if resp.status_code != 200:
|
|
62
|
+
raise DeviceFlowError(f"device authorization failed ({resp.status_code}): {resp.text}")
|
|
63
|
+
return resp.json()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def poll_for_token(
|
|
67
|
+
client: httpx.Client,
|
|
68
|
+
issuer: str,
|
|
69
|
+
client_id: str,
|
|
70
|
+
device_code: str,
|
|
71
|
+
*,
|
|
72
|
+
interval: int = 5,
|
|
73
|
+
expires_in: int = 600,
|
|
74
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
75
|
+
now: Callable[[], float] = time.monotonic,
|
|
76
|
+
) -> dict[str, Any]:
|
|
77
|
+
"""Poll the token endpoint until the user approves. Honors authorization_pending / slow_down."""
|
|
78
|
+
deadline = now() + expires_in
|
|
79
|
+
wait = interval
|
|
80
|
+
while now() < deadline:
|
|
81
|
+
resp = client.post(
|
|
82
|
+
_token_endpoint(issuer),
|
|
83
|
+
data={"grant_type": _DEVICE_GRANT, "device_code": device_code, "client_id": client_id},
|
|
84
|
+
)
|
|
85
|
+
if resp.status_code == 200:
|
|
86
|
+
return resp.json()
|
|
87
|
+
error = (
|
|
88
|
+
(resp.json() or {}).get("error")
|
|
89
|
+
if resp.headers.get("content-type", "").startswith("application/json")
|
|
90
|
+
else None
|
|
91
|
+
)
|
|
92
|
+
if error == "authorization_pending":
|
|
93
|
+
sleep(wait)
|
|
94
|
+
continue
|
|
95
|
+
if error == "slow_down":
|
|
96
|
+
wait += 5
|
|
97
|
+
sleep(wait)
|
|
98
|
+
continue
|
|
99
|
+
raise DeviceFlowError(error or f"token exchange failed ({resp.status_code})")
|
|
100
|
+
raise DeviceFlowError("device code expired before authorization")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def refresh_access_token(client: httpx.Client, issuer: str, client_id: str, refresh_token: str) -> dict[str, Any]:
|
|
104
|
+
resp = client.post(
|
|
105
|
+
_token_endpoint(issuer),
|
|
106
|
+
data={"grant_type": "refresh_token", "refresh_token": refresh_token, "client_id": client_id},
|
|
107
|
+
)
|
|
108
|
+
if resp.status_code != 200:
|
|
109
|
+
raise DeviceFlowError(f"token refresh failed ({resp.status_code}) — run `keystone login` again")
|
|
110
|
+
return resp.json()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def client_credentials_token(client: httpx.Client, issuer: str, client_id: str, client_secret: str) -> dict[str, Any]:
|
|
114
|
+
"""Mint an access token for a Keycloak **service account** (no browser, no human).
|
|
115
|
+
|
|
116
|
+
Keycloak issues no refresh token for this grant, so there is nothing to persist for renewal —
|
|
117
|
+
the secret in the environment IS the renewal mechanism.
|
|
118
|
+
"""
|
|
119
|
+
resp = client.post(
|
|
120
|
+
_token_endpoint(issuer),
|
|
121
|
+
data={"grant_type": "client_credentials", "client_id": client_id, "client_secret": client_secret},
|
|
122
|
+
)
|
|
123
|
+
if resp.status_code != 200:
|
|
124
|
+
# Surface only the OAuth error code, never the raw body: the request carried a secret and a
|
|
125
|
+
# verbose failure is the kind of thing that ends up pasted into a ticket.
|
|
126
|
+
reason = ""
|
|
127
|
+
try:
|
|
128
|
+
payload = resp.json()
|
|
129
|
+
reason = f" — {payload.get('error', '')} {payload.get('error_description', '')}".rstrip()
|
|
130
|
+
except ValueError:
|
|
131
|
+
pass
|
|
132
|
+
raise DeviceFlowError(
|
|
133
|
+
f"client-credentials grant failed ({resp.status_code}){reason}. Check {ENV_CLIENT_SECRET} and that "
|
|
134
|
+
f"the Keycloak client '{client_id}' has service accounts enabled."
|
|
135
|
+
)
|
|
136
|
+
return resp.json()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def apply_token_response(cfg: AuthConfig, token: dict[str, Any], *, now: Callable[[], float] = time.time) -> AuthConfig:
|
|
140
|
+
"""Fold a token response into the config (access + refresh + expiry). Refresh token may be rotated."""
|
|
141
|
+
cfg.access_token = token.get("access_token", "")
|
|
142
|
+
if token.get("refresh_token"):
|
|
143
|
+
cfg.refresh_token = token["refresh_token"]
|
|
144
|
+
cfg.expires_at = now() + float(token.get("expires_in", 0))
|
|
145
|
+
return cfg
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def get_access_token(
|
|
149
|
+
cfg: AuthConfig,
|
|
150
|
+
*,
|
|
151
|
+
client: httpx.Client | None = None,
|
|
152
|
+
now: Callable[[], float] = time.time,
|
|
153
|
+
save: Callable[[AuthConfig], None] | None = None,
|
|
154
|
+
env: Mapping[str, str] | None = None,
|
|
155
|
+
) -> str:
|
|
156
|
+
"""Return a valid bearer. Every command goes through here.
|
|
157
|
+
|
|
158
|
+
Order, and why: an explicitly injected ``KEYSTONE_TOKEN`` wins over the cache, because in CI a
|
|
159
|
+
stale ``~/.keystone/config.json`` left in the image would otherwise silently deploy as the wrong
|
|
160
|
+
identity. Then the cache while fresh, then renewal per :attr:`AuthConfig.auth_mode`.
|
|
161
|
+
"""
|
|
162
|
+
env = os.environ if env is None else env
|
|
163
|
+
|
|
164
|
+
injected = (env.get(ENV_TOKEN) or "").strip()
|
|
165
|
+
if injected:
|
|
166
|
+
# Used verbatim: we hold no refresh token for it and it is not ours to renew. Deliberately
|
|
167
|
+
# NOT written to the config file — persisting an injected credential would leak it out of the
|
|
168
|
+
# job that owns it into a file that outlives it.
|
|
169
|
+
return injected
|
|
170
|
+
|
|
171
|
+
if cfg.access_token and cfg.expires_at - _EXPIRY_SKEW > now():
|
|
172
|
+
return cfg.access_token
|
|
173
|
+
|
|
174
|
+
owns_client = client is None
|
|
175
|
+
client = client or httpx.Client(timeout=30)
|
|
176
|
+
try:
|
|
177
|
+
if cfg.auth_mode == MODE_CLIENT_CREDENTIALS:
|
|
178
|
+
secret = (env.get(ENV_CLIENT_SECRET) or "").strip()
|
|
179
|
+
if not secret:
|
|
180
|
+
raise DeviceFlowError(
|
|
181
|
+
f"this config was created with --client-credentials but {ENV_CLIENT_SECRET} is not set — "
|
|
182
|
+
f"export it (the grant has no refresh token, so the secret is how the token is renewed)"
|
|
183
|
+
)
|
|
184
|
+
token = client_credentials_token(client, cfg.issuer, cfg.client_id, secret)
|
|
185
|
+
else:
|
|
186
|
+
if not cfg.refresh_token:
|
|
187
|
+
raise DeviceFlowError(
|
|
188
|
+
f"not logged in — run `keystone login`, or set {ENV_TOKEN} / use --client-credentials for CI"
|
|
189
|
+
)
|
|
190
|
+
token = refresh_access_token(client, cfg.issuer, cfg.client_id, cfg.refresh_token)
|
|
191
|
+
finally:
|
|
192
|
+
if owns_client:
|
|
193
|
+
client.close()
|
|
194
|
+
apply_token_response(cfg, token, now=now)
|
|
195
|
+
if save:
|
|
196
|
+
save(cfg)
|
|
197
|
+
return cfg.access_token
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""``~/.keystone/config.json`` — CLI auth + endpoint store, mode 0600 (FDP-3170).
|
|
2
|
+
|
|
3
|
+
Holds the Keycloak issuer + client id, the platform API base (for ``deploy``), and the cached tokens.
|
|
4
|
+
Only the **refresh token** is long-lived; the access token is a short-lived cache re-derived on demand.
|
|
5
|
+
Path override via ``KEYSTONE_CONFIG`` (tests / CI).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from dataclasses import asdict, dataclass, fields
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
# Baked platform defaults so `keystone login` works with NO flags (like `aws sso login`). NOT secrets —
|
|
16
|
+
# client id is a public identifier, issuer/api are public URLs. Override via --flags / KEYSTONE_* env for
|
|
17
|
+
# other envs (qc/staging) or local testing; after first login these are cached in the config file.
|
|
18
|
+
# dev realm `aip`, Ops-provisioned device-grant client (2026-07-22). Host = keycloak-platform (public),
|
|
19
|
+
# NOT keycloak-platform-admin. Override per env with --issuer / KEYSTONE_ISSUER (qc/staging).
|
|
20
|
+
DEFAULT_ISSUER = "https://keycloak-platform.ops.onemount.dev/realms/aip"
|
|
21
|
+
DEFAULT_CLIENT_ID = "keystone-cli"
|
|
22
|
+
DEFAULT_API_BASE = "https://keystone-api-dev.aws.int.onenexus.dev"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class AuthConfig:
|
|
27
|
+
issuer: str = "" # Keycloak realm URL, e.g. https://<kc>/realms/aip-dev
|
|
28
|
+
client_id: str = "" # a PUBLIC Keycloak client with the device grant enabled (Ops-provisioned)
|
|
29
|
+
api_base: str = "" # platform API base (through Kong), used by `deploy`
|
|
30
|
+
# FDP-3072 §11.4b — the agent DATA-PLANE base (invoke/hitl/runs), which moves to the pro-code
|
|
31
|
+
# cluster's own hostname (keystone-agent-api-<env>.aws.int…) once Ops lands it. Empty = fall back
|
|
32
|
+
# to api_base, so behavior is unchanged until the new domain exists. Control-plane calls
|
|
33
|
+
# (login/deploy/status → agent-hub) always stay on api_base.
|
|
34
|
+
agent_api_base: str = ""
|
|
35
|
+
refresh_token: str = ""
|
|
36
|
+
access_token: str = ""
|
|
37
|
+
expires_at: float = 0.0 # epoch seconds — access_token expiry
|
|
38
|
+
# FDP-3170 — the workspace `deploy` targets (sent as X-Workspace-ID). A user belongs to an entity
|
|
39
|
+
# (from the JWT), but picks a workspace per session — set via `keystone workspace use`. The name is
|
|
40
|
+
# cached only for display. load() ignores unknown keys, so older config files stay compatible.
|
|
41
|
+
workspace_id: str = ""
|
|
42
|
+
workspace_name: str = ""
|
|
43
|
+
# FDP-3441 — which grant minted these tokens, so the read path knows how to renew them.
|
|
44
|
+
# ``device`` (default, a human approved in a browser → renew with the refresh token) or
|
|
45
|
+
# ``client_credentials`` (a CI service account → no refresh token exists; renew by re-running
|
|
46
|
+
# the grant with the secret from the environment). Older config files lack the key and load as
|
|
47
|
+
# ``device``, which is what they are.
|
|
48
|
+
auth_mode: str = "device"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def config_path() -> Path:
|
|
52
|
+
"""Config file path. ``KEYSTONE_CONFIG`` overrides (tests); default ``~/.keystone/config.json``."""
|
|
53
|
+
override = os.environ.get("KEYSTONE_CONFIG")
|
|
54
|
+
return Path(override) if override else Path.home() / ".keystone" / "config.json"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def load() -> AuthConfig:
|
|
58
|
+
path = config_path()
|
|
59
|
+
if not path.exists():
|
|
60
|
+
return AuthConfig()
|
|
61
|
+
data = json.loads(path.read_text())
|
|
62
|
+
known = {f.name for f in fields(AuthConfig)}
|
|
63
|
+
return AuthConfig(**{k: v for k, v in data.items() if k in known})
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def save(cfg: AuthConfig) -> None:
|
|
67
|
+
path = config_path()
|
|
68
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
# Write then lock to 0600 (owner-only) — the refresh token is a credential.
|
|
70
|
+
path.write_text(json.dumps(asdict(cfg), indent=2))
|
|
71
|
+
path.chmod(0o600)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""``keystone`` command groups."""
|