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,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})")
@@ -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