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.
Files changed (54) hide show
  1. keystone_cli/__init__.py +4 -0
  2. keystone_cli/__main__.py +24 -0
  3. keystone_cli/auth/__init__.py +5 -0
  4. keystone_cli/auth/device_flow.py +197 -0
  5. keystone_cli/auth/token_store.py +71 -0
  6. keystone_cli/commands/__init__.py +1 -0
  7. keystone_cli/commands/agent.py +1017 -0
  8. keystone_cli/commands/dev.py +57 -0
  9. keystone_cli/commands/login.py +207 -0
  10. keystone_cli/commands/workspace.py +87 -0
  11. keystone_cli/devloop.py +183 -0
  12. keystone_cli/platform_client.py +75 -0
  13. keystone_cli/runner.py +87 -0
  14. keystone_cli/scaffold.py +81 -0
  15. keystone_cli/templates/blank/README.md.tmpl +25 -0
  16. keystone_cli/templates/blank/agent.yaml.tmpl +20 -0
  17. keystone_cli/templates/blank/env.tmpl +10 -0
  18. keystone_cli/templates/blank/gitignore.tmpl +7 -0
  19. keystone_cli/templates/blank/pkg/__init__.py.tmpl +0 -0
  20. keystone_cli/templates/blank/pkg/graph.py.tmpl +33 -0
  21. keystone_cli/templates/blank/pyproject.toml.tmpl +12 -0
  22. keystone_cli/templates/hitl/README.md.tmpl +33 -0
  23. keystone_cli/templates/hitl/agent.yaml.tmpl +33 -0
  24. keystone_cli/templates/hitl/env.tmpl +10 -0
  25. keystone_cli/templates/hitl/gitignore.tmpl +7 -0
  26. keystone_cli/templates/hitl/pkg/__init__.py.tmpl +0 -0
  27. keystone_cli/templates/hitl/pkg/graph.py.tmpl +115 -0
  28. keystone_cli/templates/hitl/pyproject.toml.tmpl +12 -0
  29. keystone_cli/templates/llm/README.md.tmpl +29 -0
  30. keystone_cli/templates/llm/agent.yaml.tmpl +29 -0
  31. keystone_cli/templates/llm/env.tmpl +10 -0
  32. keystone_cli/templates/llm/gitignore.tmpl +7 -0
  33. keystone_cli/templates/llm/pkg/__init__.py.tmpl +0 -0
  34. keystone_cli/templates/llm/pkg/graph.py.tmpl +53 -0
  35. keystone_cli/templates/llm/pyproject.toml.tmpl +12 -0
  36. keystone_cli/templates/rag-qa/README.md.tmpl +23 -0
  37. keystone_cli/templates/rag-qa/agent.yaml.tmpl +34 -0
  38. keystone_cli/templates/rag-qa/env.tmpl +10 -0
  39. keystone_cli/templates/rag-qa/gitignore.tmpl +7 -0
  40. keystone_cli/templates/rag-qa/pkg/__init__.py.tmpl +0 -0
  41. keystone_cli/templates/rag-qa/pkg/graph.py.tmpl +69 -0
  42. keystone_cli/templates/rag-qa/pyproject.toml.tmpl +12 -0
  43. keystone_cli/templates/tool-agent/README.md.tmpl +30 -0
  44. keystone_cli/templates/tool-agent/agent.yaml.tmpl +25 -0
  45. keystone_cli/templates/tool-agent/env.tmpl +10 -0
  46. keystone_cli/templates/tool-agent/gitignore.tmpl +7 -0
  47. keystone_cli/templates/tool-agent/pkg/__init__.py.tmpl +0 -0
  48. keystone_cli/templates/tool-agent/pkg/graph.py.tmpl +89 -0
  49. keystone_cli/templates/tool-agent/pyproject.toml.tmpl +15 -0
  50. keystone_cli-0.1.0.dist-info/METADATA +13 -0
  51. keystone_cli-0.1.0.dist-info/RECORD +54 -0
  52. keystone_cli-0.1.0.dist-info/WHEEL +5 -0
  53. keystone_cli-0.1.0.dist-info/entry_points.txt +2 -0
  54. keystone_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,4 @@
1
+ """Keystone pro-code agent CLI (``keystone``) — a thin command layer over
2
+ ``keystone-agent-sdk``. M1: ``keystone agent validate`` + ``keystone agent run`` (local dev
3
+ loop). ``keystone login`` / ``keystone agent deploy`` = M2 (same package, extended).
4
+ """
@@ -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."""