geyser-open 0.1.0b4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,24 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .pytest_cache/
6
+ .mypy_cache/
7
+ .ruff_cache/
8
+ .coverage
9
+ htmlcov/
10
+ dist/
11
+ build/
12
+ .geyser/
13
+ .release-build/
14
+ .release-python/
15
+ release-standalone/
16
+ release-assets/
17
+ *.tar.gz
18
+ *.whl
19
+ *.sig
20
+ *.crt
21
+ *.pem
22
+ *.key
23
+ coverage.xml
24
+ site/
@@ -0,0 +1,34 @@
1
+ Metadata-Version: 2.5
2
+ Name: geyser-open
3
+ Version: 0.1.0b4
4
+ Summary: Public developer CLI for durable Geyser agents
5
+ Project-URL: Homepage, https://geyserlabs.ai/developers
6
+ Project-URL: Documentation, https://docs.geyserlabs.ai
7
+ Project-URL: Repository, https://github.com/geyserlabs/geyser-open
8
+ Project-URL: Security, https://github.com/geyserlabs/geyser-open/security/policy
9
+ Author-email: Geyser Labs <security@geyserlabs.ai>
10
+ License-Expression: MIT
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.11
20
+ Requires-Dist: geyser-sdk==0.1.0b4
21
+ Requires-Dist: keyring<26,>=25.6
22
+ Requires-Dist: platformdirs<5,>=4.3
23
+ Provides-Extra: test
24
+ Requires-Dist: pytest-asyncio<2,>=0.24; extra == 'test'
25
+ Requires-Dist: pytest<10,>=8; extra == 'test'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # geyser-open
29
+
30
+ Installs the `geyser` command for scaffolding, validating, testing, packaging,
31
+ signing, staging, canarying, and inspecting durable Geyser work.
32
+
33
+ `--yes` skips only the local confirmation prompt. It never bypasses server
34
+ scopes, tenant policy, approval binding, qualification, or promotion gates.
@@ -0,0 +1,7 @@
1
+ # geyser-open
2
+
3
+ Installs the `geyser` command for scaffolding, validating, testing, packaging,
4
+ signing, staging, canarying, and inspecting durable Geyser work.
5
+
6
+ `--yes` skips only the local confirmation prompt. It never bypasses server
7
+ scopes, tenant policy, approval binding, qualification, or promotion gates.
@@ -0,0 +1,45 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27,<2"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "geyser-open"
7
+ version = "0.1.0b4"
8
+ description = "Public developer CLI for durable Geyser agents"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.11"
12
+ authors = [{ name = "Geyser Labs", email = "security@geyserlabs.ai" }]
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "Environment :: Console",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Typing :: Typed",
22
+ ]
23
+ dependencies = [
24
+ "geyser-sdk==0.1.0b4",
25
+ "keyring>=25.6,<26",
26
+ "platformdirs>=4.3,<5",
27
+ ]
28
+
29
+ [project.optional-dependencies]
30
+ test = ["pytest>=8,<10", "pytest-asyncio>=0.24,<2"]
31
+
32
+ [project.scripts]
33
+ geyser = "geyser_cli.__main__:main"
34
+
35
+ [project.urls]
36
+ Homepage = "https://geyserlabs.ai/developers"
37
+ Documentation = "https://docs.geyserlabs.ai"
38
+ Repository = "https://github.com/geyserlabs/geyser-open"
39
+ Security = "https://github.com/geyserlabs/geyser-open/security/policy"
40
+
41
+ [tool.hatch.build.targets.wheel]
42
+ packages = ["src/geyser_cli"]
43
+
44
+ [tool.hatch.build.targets.sdist]
45
+ include = ["src/geyser_cli", "tests", "README.md", "pyproject.toml"]
@@ -0,0 +1,3 @@
1
+ """Geyser developer CLI."""
2
+
3
+ __version__ = "0.1.0b4"
@@ -0,0 +1,375 @@
1
+ """Command line entry point for the public Geyser developer platform."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import base64
7
+ import json
8
+ import os
9
+ import shutil
10
+ import subprocess
11
+ import sys
12
+ import uuid
13
+ from collections.abc import Sequence
14
+ from pathlib import Path
15
+ from typing import Any, cast
16
+
17
+ import httpx
18
+ from geyser_sdk import (
19
+ ApprovalDecision,
20
+ CancelRequest,
21
+ ForkCreate,
22
+ GeyserClient,
23
+ LocalEmulator,
24
+ PackagePromotion,
25
+ PackageUpload,
26
+ ProblemError,
27
+ TypedTask,
28
+ bytes_digest,
29
+ normalize_contract,
30
+ validate_outcome,
31
+ )
32
+
33
+ from . import __version__
34
+ from .auth import DeviceAuthorization, login_device, login_service_token
35
+ from .credentials import CredentialStore
36
+ from .extensions import inspect_archive, package_extension, test_extension, validate_extension
37
+ from .output import emit
38
+ from .scaffolds import KINDS, scaffold
39
+
40
+ DEFAULT_API_URL = "https://agents.geyserlabs.ai"
41
+ DEFAULT_SCOPES = ["development:read", "runs:read", "packages:upload", "packages:stage"]
42
+
43
+
44
+ def _add_path(command: argparse.ArgumentParser) -> None:
45
+ command.add_argument("path", nargs="?", type=Path, default=Path.cwd())
46
+
47
+
48
+ def _parser() -> argparse.ArgumentParser:
49
+ parser = argparse.ArgumentParser(prog="geyser", description="Build governed durable agents.")
50
+ parser.add_argument("--api-url", default=os.getenv("GEYSER_API_URL", DEFAULT_API_URL))
51
+ parser.add_argument("--profile", default=os.getenv("GEYSER_PROFILE", "default"))
52
+ parser.add_argument("--json", action="store_true", help="emit stable JSON")
53
+ parser.add_argument("--allow-file-credentials", action="store_true")
54
+ commands = parser.add_subparsers(dest="command", required=True)
55
+
56
+ login = commands.add_parser("login", help="authenticate with OAuth device flow")
57
+ login.add_argument("--scope", action="append", dest="scopes")
58
+ login.add_argument("--no-browser", action="store_true")
59
+ login.add_argument("--service-token-stdin", action="store_true", help=argparse.SUPPRESS)
60
+ commands.add_parser("logout", help="remove the current profile credential")
61
+ commands.add_parser("doctor", help="check local configuration and public API reachability")
62
+
63
+ init = commands.add_parser("init", help="create a safe extension scaffold")
64
+ init.add_argument("kind", choices=KINDS)
65
+ init.add_argument("name")
66
+ init.add_argument("--output", type=Path, default=Path.cwd())
67
+ for name in ("validate", "test", "dev", "package"):
68
+ _add_path(commands.add_parser(name))
69
+ validate_result = commands.add_parser("validate-outcome")
70
+ validate_result.add_argument("contract", type=Path)
71
+ validate_result.add_argument("result", type=Path)
72
+ sign = commands.add_parser("sign", help="sign exact package bytes using Sigstore")
73
+ sign.add_argument("archive", type=Path)
74
+ sign.add_argument("--bundle", type=Path)
75
+ publish = commands.add_parser("publish", help="upload exact signed bytes to staging")
76
+ publish.add_argument("archive", type=Path)
77
+ publish.add_argument("--stage", action="store_true", required=True)
78
+ publish.add_argument("--signature-bundle", type=Path)
79
+ publish.add_argument("--yes", action="store_true")
80
+ promote = commands.add_parser("promote", help="request promotion of exact staged bytes")
81
+ promote.add_argument("package_id")
82
+ promote.add_argument("--digest", required=True)
83
+ promote.add_argument("--canary", action="store_true", required=True)
84
+ promote.add_argument("--yes", action="store_true")
85
+ commands.add_parser("status", help="list package lifecycle state")
86
+
87
+ runs = commands.add_parser("runs")
88
+ run_commands = runs.add_subparsers(dest="runs_command", required=True)
89
+ run_list = run_commands.add_parser("list")
90
+ run_list.add_argument("--customer", action="store_true")
91
+ for name in ("get", "watch", "trace"):
92
+ command = run_commands.add_parser(name)
93
+ command.add_argument("run_id")
94
+ command.add_argument("--customer", action="store_true")
95
+ fork = run_commands.add_parser("fork")
96
+ fork.add_argument("run_id")
97
+ fork.add_argument("--child-run-id", required=True)
98
+ fork.add_argument("--expected-sequence", type=int, required=True)
99
+ fork.add_argument("--reason-code", default="developer_requested")
100
+ fork.add_argument("--yes", action="store_true")
101
+ stop = run_commands.add_parser("stop")
102
+ stop.add_argument("run_id")
103
+ stop.add_argument("--expected-sequence", type=int, required=True)
104
+ stop.add_argument("--reason-code", default="developer_requested")
105
+ stop.add_argument("--yes", action="store_true")
106
+
107
+ approvals = commands.add_parser("approvals")
108
+ approval_commands = approvals.add_subparsers(dest="approvals_command", required=True)
109
+ approval_commands.add_parser("list")
110
+ approval_get = approval_commands.add_parser("get")
111
+ approval_get.add_argument("approval_id")
112
+ decide = approval_commands.add_parser("decide")
113
+ decide.add_argument("run_id")
114
+ decide.add_argument("approval_id")
115
+ decide.add_argument("decision", choices=("approve", "reject"))
116
+ decide.add_argument("--expected-sequence", type=int, required=True)
117
+ decide.add_argument("--binding-digest", required=True)
118
+ decide.add_argument("--reason-code", required=True)
119
+ decide.add_argument("--yes", action="store_true")
120
+ capabilities = commands.add_parser("capabilities")
121
+ capabilities.add_argument("--agent", required=True)
122
+ commands.add_parser("version")
123
+ return parser
124
+
125
+
126
+ def _store(args: argparse.Namespace) -> CredentialStore:
127
+ return CredentialStore(args.profile, allow_file_fallback=args.allow_file_credentials)
128
+
129
+
130
+ def _client(args: argparse.Namespace) -> GeyserClient:
131
+ credential = _store(args).load()
132
+ if credential is None:
133
+ raise RuntimeError("not authenticated; run `geyser login`")
134
+ return GeyserClient(args.api_url, credential.access_token)
135
+
136
+
137
+ def _confirm(args: argparse.Namespace, preview: dict[str, Any]) -> None:
138
+ emit({"preview": preview, "server_authority_required": True}, machine=args.json)
139
+ if args.yes:
140
+ return
141
+ if not sys.stdin.isatty() or input("Continue? [y/N] ").strip().casefold() not in {"y", "yes"}:
142
+ raise RuntimeError("operation canceled before server authorization")
143
+
144
+
145
+ def _manifest_from_archive(path: Path) -> dict[str, Any]:
146
+ import zipfile
147
+
148
+ with zipfile.ZipFile(path) as archive:
149
+ return cast(dict[str, Any], json.loads(archive.read("geyser-package.json")))
150
+
151
+
152
+ def _handle_local(args: argparse.Namespace) -> Any:
153
+ if args.command == "init":
154
+ return {"path": os.fspath(scaffold(args.kind, args.name, args.output)), "created": True}
155
+ if args.command == "validate":
156
+ return {"valid": True, **validate_extension(args.path)}
157
+ if args.command == "test":
158
+ result = test_extension(args.path)
159
+ if result["failed"]:
160
+ raise RuntimeError(f"frozen cases failed: {result['failed']}")
161
+ return result
162
+ if args.command == "package":
163
+ return package_extension(args.path)
164
+ if args.command == "validate-outcome":
165
+ contract = normalize_contract(json.loads(args.contract.read_text(encoding="utf-8")))
166
+ if contract is None:
167
+ raise ValueError("outcome contract is empty")
168
+ validate_outcome(contract, json.loads(args.result.read_text(encoding="utf-8")))
169
+ return {"valid": True, "contract_digest": contract["contract_digest"]}
170
+ if args.command == "dev":
171
+ root = args.path.expanduser().resolve()
172
+ result = test_extension(root)
173
+ task = TypedTask(
174
+ task_id="local-task",
175
+ context_id="local-context",
176
+ prompt_digest="sha256:" + "0" * 64,
177
+ model_ref="deterministic:echo",
178
+ )
179
+ emulator = LocalEmulator()
180
+ emulator.admit("local-run", task)
181
+ emulator.append(
182
+ "local-run",
183
+ event_type="run.completed",
184
+ key="complete",
185
+ expected_sequence=1,
186
+ data={
187
+ "extension_digest": bytes_digest(
188
+ root.joinpath("geyser-package.json").read_bytes()
189
+ )
190
+ },
191
+ )
192
+ return {"extension": result, "run": emulator.project("local-run"), "network_used": False}
193
+ raise RuntimeError("unknown local command")
194
+
195
+
196
+ def _doctor(args: argparse.Namespace) -> dict[str, Any]:
197
+ credential = _store(args).load()
198
+ reachable = False
199
+ api_version = ""
200
+ try:
201
+ response = httpx.get(f"{args.api_url.rstrip('/')}/api/v1/openapi.json", timeout=5)
202
+ reachable = response.status_code == 200
203
+ if reachable:
204
+ api_version = str(response.json().get("info", {}).get("version") or "")
205
+ except httpx.HTTPError:
206
+ pass
207
+ return {
208
+ "python": sys.version.split()[0],
209
+ "cli_version": __version__,
210
+ "api_url": args.api_url,
211
+ "api_reachable": reachable,
212
+ "api_version": api_version,
213
+ "authenticated": credential is not None,
214
+ "profile": args.profile,
215
+ }
216
+
217
+
218
+ def _handle_network(args: argparse.Namespace) -> Any:
219
+ with _client(args) as client:
220
+ if args.command == "status":
221
+ return client.list_packages()
222
+ if args.command == "capabilities":
223
+ return client.capabilities(agent_name=args.agent)
224
+ if args.command == "runs":
225
+ if args.runs_command == "list":
226
+ return client.list_runs(customer=args.customer)
227
+ if args.runs_command == "get":
228
+ return client.get_run(args.run_id, customer=args.customer)
229
+ if args.runs_command == "trace":
230
+ return client.trace(args.run_id, customer=args.customer)
231
+ if args.runs_command == "watch":
232
+ return {"events": list(client.watch_events(args.run_id, customer=args.customer))}
233
+ if args.runs_command == "stop":
234
+ operation = {
235
+ "operation": "stop_run",
236
+ "run_id": args.run_id,
237
+ "expected_sequence": args.expected_sequence,
238
+ }
239
+ _confirm(args, operation)
240
+ request = CancelRequest(
241
+ cancellation_id="cancel_" + uuid.uuid4().hex,
242
+ expected_sequence=args.expected_sequence,
243
+ reason_code=args.reason_code,
244
+ )
245
+ return client.cancel_run(args.run_id, request)
246
+ operation = {
247
+ "operation": "fork_run",
248
+ "run_id": args.run_id,
249
+ "child_run_id": args.child_run_id,
250
+ "expected_sequence": args.expected_sequence,
251
+ }
252
+ _confirm(args, operation)
253
+ fork_request = ForkCreate(
254
+ fork_id="fork_" + uuid.uuid4().hex,
255
+ child_run_id=args.child_run_id,
256
+ expected_sequence=args.expected_sequence,
257
+ reason_code=args.reason_code,
258
+ )
259
+ return client.fork(args.run_id, fork_request)
260
+ if args.command == "approvals":
261
+ if args.approvals_command == "list":
262
+ return client.list_approvals()
263
+ if args.approvals_command == "get":
264
+ return client.get_approval(args.approval_id)
265
+ operation = {
266
+ "operation": "approval_decision",
267
+ "run_id": args.run_id,
268
+ "approval_id": args.approval_id,
269
+ "decision": args.decision,
270
+ "binding_digest": args.binding_digest,
271
+ }
272
+ _confirm(args, operation)
273
+ decision = ApprovalDecision(
274
+ decision_id="decision_" + uuid.uuid4().hex,
275
+ decision=args.decision,
276
+ expected_approval_sequence=args.expected_sequence,
277
+ binding_digest=args.binding_digest,
278
+ reason_code=args.reason_code,
279
+ )
280
+ return client.decide_approval(args.run_id, args.approval_id, decision)
281
+ if args.command == "publish":
282
+ archive = args.archive.expanduser().resolve()
283
+ details = inspect_archive(archive)
284
+ manifest = _manifest_from_archive(archive)
285
+ _confirm(args, {"operation": "package_upload", "stage": "staging", **details})
286
+ bundle = {}
287
+ if args.signature_bundle:
288
+ bundle = json.loads(args.signature_bundle.read_text(encoding="utf-8"))
289
+ upload = PackageUpload(
290
+ name=str(manifest["name"]),
291
+ version=str(manifest["version"]),
292
+ digest=str(details["digest"]),
293
+ media_type="application/vnd.geyser.extension+zip",
294
+ content_base64=base64.b64encode(archive.read_bytes()).decode(),
295
+ signature_bundle=bundle,
296
+ )
297
+ return client.upload_package(upload, idempotency_key=str(details["digest"]))
298
+ if args.command == "promote":
299
+ _confirm(args, {
300
+ "operation": "package_promotion",
301
+ "package_id": args.package_id,
302
+ "target": "canary",
303
+ "expected_digest": args.digest,
304
+ })
305
+ promotion = PackagePromotion(
306
+ promotion_id="promote_" + uuid.uuid4().hex,
307
+ target="canary",
308
+ expected_digest=args.digest,
309
+ )
310
+ return client.promote_package(args.package_id, promotion)
311
+ raise RuntimeError("unknown network command")
312
+
313
+
314
+ def main(argv: Sequence[str] | None = None) -> int:
315
+ args = _parser().parse_args(argv)
316
+ try:
317
+ if args.command == "version":
318
+ value: Any = {"geyser_open": __version__}
319
+ elif args.command == "login":
320
+ store = _store(args)
321
+ if args.service_token_stdin:
322
+ value = login_service_token(store, sys.stdin.readline())
323
+ else:
324
+ def notify(device: DeviceAuthorization) -> None:
325
+ emit({
326
+ "verification_uri": device.verification_uri,
327
+ "user_code": device.user_code,
328
+ }, machine=args.json)
329
+
330
+ value = login_device(
331
+ args.api_url,
332
+ store,
333
+ scopes=args.scopes or DEFAULT_SCOPES,
334
+ open_browser=not args.no_browser,
335
+ notify=notify,
336
+ )
337
+ elif args.command == "logout":
338
+ value = {"removed": _store(args).delete(), "profile": args.profile}
339
+ elif args.command == "doctor":
340
+ value = _doctor(args)
341
+ elif args.command in {"init", "validate", "validate-outcome", "test", "dev", "package"}:
342
+ value = _handle_local(args)
343
+ elif args.command == "sign":
344
+ archive = args.archive.expanduser().resolve()
345
+ inspect_archive(archive)
346
+ bundle = (
347
+ args.bundle or archive.with_suffix(archive.suffix + ".sigstore.json")
348
+ ).resolve()
349
+ executable = shutil.which("sigstore")
350
+ if executable is None:
351
+ raise RuntimeError("Sigstore CLI is not installed")
352
+ result = subprocess.run( # noqa: S603 - resolved executable and argv, no shell
353
+ [executable, "sign", "--bundle", os.fspath(bundle), os.fspath(archive)],
354
+ check=False,
355
+ )
356
+ if result.returncode:
357
+ raise RuntimeError("Sigstore signing failed; no package lifecycle state changed")
358
+ value = {"signed": True, "archive": os.fspath(archive), "bundle": os.fspath(bundle)}
359
+ else:
360
+ value = _handle_network(args)
361
+ emit(value, machine=args.json)
362
+ return 0
363
+ except (OSError, ValueError, RuntimeError, ProblemError, httpx.HTTPError) as exc:
364
+ if args.json:
365
+ emit({"error": type(exc).__name__, "detail": str(exc)}, machine=True)
366
+ else:
367
+ print(f"geyser: {exc}", file=sys.stderr)
368
+ return 2
369
+
370
+
371
+ if __name__ == "__main__":
372
+ raise SystemExit(main())
373
+
374
+
375
+ __all__ = ["main"]
@@ -0,0 +1,100 @@
1
+ """OAuth device flow for humans and bounded service tokens for CI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ import webbrowser
7
+ from collections.abc import Callable
8
+ from dataclasses import dataclass
9
+ from typing import Any
10
+
11
+ import httpx
12
+
13
+ from .credentials import CredentialStore, StoredCredential
14
+
15
+ CLIENT_ID = "geyser-public-cli"
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class DeviceAuthorization:
20
+ device_code: str
21
+ user_code: str
22
+ verification_uri: str
23
+ verification_uri_complete: str
24
+ expires_in: int
25
+ interval: int
26
+
27
+
28
+ def login_device(
29
+ base_url: str,
30
+ store: CredentialStore,
31
+ *,
32
+ scopes: list[str],
33
+ open_browser: bool = True,
34
+ timeout: float = 10.0,
35
+ notify: Callable[[DeviceAuthorization], None] | None = None,
36
+ ) -> dict[str, Any]:
37
+ with httpx.Client(base_url=base_url.rstrip("/"), timeout=timeout) as client:
38
+ response = client.post(
39
+ "/api/v1/oauth/device/code",
40
+ json={"client_id": CLIENT_ID, "scope": " ".join(scopes)},
41
+ )
42
+ if response.status_code >= 400:
43
+ raise RuntimeError(
44
+ f"device authorization could not start ({response.status_code})"
45
+ )
46
+ device = DeviceAuthorization(**response.json())
47
+ if notify:
48
+ notify(device)
49
+ if open_browser:
50
+ webbrowser.open(device.verification_uri_complete or device.verification_uri)
51
+ deadline = time.monotonic() + device.expires_in
52
+ interval = max(1, device.interval)
53
+ while time.monotonic() < deadline:
54
+ token_response = client.post(
55
+ "/api/v1/oauth/token",
56
+ json={
57
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
58
+ "device_code": device.device_code,
59
+ "client_id": CLIENT_ID,
60
+ },
61
+ )
62
+ value = token_response.json()
63
+ if token_response.status_code == 200:
64
+ credential = StoredCredential(
65
+ access_token=str(value["access_token"]),
66
+ token_type=str(value.get("token_type") or "Bearer"),
67
+ expires_at=time.time() + float(value.get("expires_in") or 0),
68
+ scope=str(value.get("scope") or ""),
69
+ customer_id=int(value.get("customer_id") or 0),
70
+ project_id=str(value.get("project_id") or ""),
71
+ )
72
+ backend = store.save(credential)
73
+ return {
74
+ "authenticated": True,
75
+ "storage": backend,
76
+ "scope": credential.scope.split(),
77
+ "customer_id": credential.customer_id,
78
+ "project_id": credential.project_id,
79
+ }
80
+ error = str(value.get("error") or "")
81
+ if error == "authorization_pending":
82
+ time.sleep(interval)
83
+ continue
84
+ if error == "slow_down":
85
+ interval += 5
86
+ time.sleep(interval)
87
+ continue
88
+ failure = error or str(token_response.status_code)
89
+ raise RuntimeError(f"device authorization failed: {failure}")
90
+ raise RuntimeError("device authorization expired")
91
+
92
+
93
+ def login_service_token(store: CredentialStore, token: str) -> dict[str, Any]:
94
+ if not token.strip():
95
+ raise ValueError("service token input was empty")
96
+ backend = store.save(StoredCredential(access_token=token.strip()))
97
+ return {"authenticated": True, "storage": backend, "credential_type": "service"}
98
+
99
+
100
+ __all__ = ["CLIENT_ID", "DeviceAuthorization", "login_device", "login_service_token"]
@@ -0,0 +1,97 @@
1
+ """Credential storage with keychain-first, explicitly opted-in file fallback."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import stat
8
+ from dataclasses import dataclass
9
+
10
+ import keyring
11
+ from keyring.errors import KeyringError
12
+ from platformdirs import user_config_path
13
+
14
+ SERVICE = "ai.geyser.developer-cli"
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class StoredCredential:
19
+ access_token: str
20
+ token_type: str = "Bearer" # noqa: S105 - OAuth token scheme, not a secret
21
+ expires_at: float = 0
22
+ scope: str = ""
23
+ customer_id: int = 0
24
+ project_id: str = ""
25
+
26
+
27
+ class CredentialStore:
28
+ def __init__(self, profile: str = "default", *, allow_file_fallback: bool = False) -> None:
29
+ self.profile = profile
30
+ self.allow_file_fallback = allow_file_fallback
31
+ self.path = user_config_path("geyser", "Geyser Labs") / "credentials.json"
32
+
33
+ def save(self, credential: StoredCredential) -> str:
34
+ payload = json.dumps(credential.__dict__ if hasattr(credential, "__dict__") else {
35
+ "access_token": credential.access_token,
36
+ "token_type": credential.token_type,
37
+ "expires_at": credential.expires_at,
38
+ "scope": credential.scope,
39
+ "customer_id": credential.customer_id,
40
+ "project_id": credential.project_id,
41
+ })
42
+ try:
43
+ keyring.set_password(SERVICE, self.profile, payload)
44
+ return "os-keychain"
45
+ except (KeyringError, RuntimeError):
46
+ if not self.allow_file_fallback:
47
+ raise RuntimeError(
48
+ "OS keychain is unavailable; rerun with --allow-file-credentials to opt in "
49
+ "to a permission-restricted file"
50
+ ) from None
51
+ self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
52
+ existing: dict[str, str] = {}
53
+ if self.path.exists():
54
+ existing = json.loads(self.path.read_text(encoding="utf-8"))
55
+ existing[self.profile] = payload
56
+ temporary = self.path.with_suffix(".tmp")
57
+ temporary.write_text(json.dumps(existing), encoding="utf-8")
58
+ os.chmod(temporary, stat.S_IRUSR | stat.S_IWUSR)
59
+ temporary.replace(self.path)
60
+ return "restricted-file"
61
+
62
+ def load(self) -> StoredCredential | None:
63
+ payload: str | None = None
64
+ try:
65
+ payload = keyring.get_password(SERVICE, self.profile)
66
+ except (KeyringError, RuntimeError):
67
+ pass
68
+ if payload is None and self.allow_file_fallback and self.path.exists():
69
+ mode = stat.S_IMODE(self.path.stat().st_mode)
70
+ if mode & 0o077:
71
+ raise RuntimeError("credential fallback file permissions are not 0600")
72
+ payload = json.loads(self.path.read_text(encoding="utf-8")).get(self.profile)
73
+ if payload is None:
74
+ return None
75
+ value = json.loads(payload)
76
+ return StoredCredential(**value)
77
+
78
+ def delete(self) -> bool:
79
+ removed = False
80
+ try:
81
+ if keyring.get_password(SERVICE, self.profile) is not None:
82
+ keyring.delete_password(SERVICE, self.profile)
83
+ removed = True
84
+ except (KeyringError, RuntimeError):
85
+ pass
86
+ if self.allow_file_fallback and self.path.exists():
87
+ values = json.loads(self.path.read_text(encoding="utf-8"))
88
+ removed = values.pop(self.profile, None) is not None or removed
89
+ if values:
90
+ self.path.write_text(json.dumps(values), encoding="utf-8")
91
+ os.chmod(self.path, 0o600)
92
+ else:
93
+ self.path.unlink()
94
+ return removed
95
+
96
+
97
+ __all__ = ["CredentialStore", "StoredCredential"]
@@ -0,0 +1,151 @@
1
+ """Validation, deterministic packaging, and frozen extension tests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import stat
8
+ import zipfile
9
+ from pathlib import Path, PurePosixPath
10
+ from typing import Any
11
+
12
+ from geyser_sdk import bytes_digest
13
+ from pydantic import BaseModel, ConfigDict, Field
14
+
15
+ from .scaffolds import KINDS
16
+
17
+ MAX_FILE_BYTES = 2 * 1024 * 1024
18
+ MAX_PACKAGE_BYTES = 10 * 1024 * 1024
19
+ IGNORED_PARTS = frozenset({".git", ".geyser", ".venv", "__pycache__"})
20
+
21
+
22
+ class ExtensionManifest(BaseModel):
23
+ model_config = ConfigDict(extra="forbid")
24
+
25
+ schema_version: int = 1
26
+ kind: str
27
+ name: str
28
+ version: str
29
+ permissions: list[str] = Field(default_factory=list, max_length=128)
30
+
31
+
32
+ def _load_json(path: Path) -> Any:
33
+ try:
34
+ return json.loads(path.read_text(encoding="utf-8"))
35
+ except (OSError, UnicodeError, json.JSONDecodeError) as exc:
36
+ raise ValueError(f"invalid JSON file {path}: {exc}") from exc
37
+
38
+
39
+ def validate_extension(root: Path) -> dict[str, Any]:
40
+ root = root.expanduser().resolve()
41
+ if not root.is_dir():
42
+ raise ValueError(f"extension directory does not exist: {root}")
43
+ manifest_path = root / "geyser-package.json"
44
+ manifest = ExtensionManifest.model_validate(_load_json(manifest_path))
45
+ if manifest.schema_version != 1 or manifest.kind not in KINDS:
46
+ raise ValueError("unsupported extension manifest kind or schema version")
47
+ files = 0
48
+ total = 0
49
+ for path in sorted(root.rglob("*")):
50
+ relative = path.relative_to(root)
51
+ if set(relative.parts) & IGNORED_PARTS:
52
+ continue
53
+ mode = path.lstat().st_mode
54
+ if stat.S_ISLNK(mode) or not (stat.S_ISREG(mode) or stat.S_ISDIR(mode)):
55
+ raise ValueError(f"extension contains unsafe file type: {relative}")
56
+ if path.is_file():
57
+ size = path.stat().st_size
58
+ if size > MAX_FILE_BYTES:
59
+ raise ValueError(f"extension file exceeds 2 MiB: {relative}")
60
+ files += 1
61
+ total += size
62
+ if total > MAX_PACKAGE_BYTES:
63
+ raise ValueError("extension exceeds the 10 MiB unpacked bound")
64
+ cases_path = root / "evals" / "cases.json"
65
+ cases = _load_json(cases_path)
66
+ if not isinstance(cases, dict) or cases.get("frozen") is not True:
67
+ raise ValueError("evals/cases.json must declare frozen=true")
68
+ rows = cases.get("cases")
69
+ if not isinstance(rows, list) or not rows:
70
+ raise ValueError("evals/cases.json must contain at least one case")
71
+ if not any(isinstance(row, dict) and row.get("critical") is True for row in rows):
72
+ raise ValueError("at least one frozen critical denial case is required")
73
+ return {"manifest": manifest.model_dump(), "files": files, "size_bytes": total}
74
+
75
+
76
+ def test_extension(root: Path) -> dict[str, Any]:
77
+ validation = validate_extension(root)
78
+ cases = _load_json(root.expanduser().resolve() / "evals" / "cases.json")["cases"]
79
+ failed = [row.get("case_id", "unknown") for row in cases if row.get("expected") not in {
80
+ "success", "deny_without_explicit_authority"
81
+ }]
82
+ return {
83
+ **validation,
84
+ "cases": len(cases),
85
+ "passed": len(cases) - len(failed),
86
+ "failed": failed,
87
+ }
88
+
89
+
90
+ def package_extension(root: Path, output: Path | None = None) -> dict[str, Any]:
91
+ root = root.expanduser().resolve()
92
+ validation = validate_extension(root)
93
+ manifest = validation["manifest"]
94
+ output = output or root / ".geyser" / "dist" / (
95
+ f"{manifest['name']}-{manifest['version']}.geyser.zip"
96
+ )
97
+ output = output.expanduser().resolve()
98
+ output.parent.mkdir(parents=True, exist_ok=True)
99
+ with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
100
+ for path in sorted(root.rglob("*")):
101
+ relative = path.relative_to(root)
102
+ if not path.is_file() or set(relative.parts) & IGNORED_PARTS:
103
+ continue
104
+ info = zipfile.ZipInfo(PurePosixPath(relative).as_posix(), (1980, 1, 1, 0, 0, 0))
105
+ info.compress_type = zipfile.ZIP_DEFLATED
106
+ info.external_attr = (stat.S_IFREG | 0o644) << 16
107
+ archive.writestr(info, path.read_bytes())
108
+ package_bytes = output.read_bytes()
109
+ if len(package_bytes) > MAX_PACKAGE_BYTES:
110
+ output.unlink(missing_ok=True)
111
+ raise ValueError("extension archive exceeds 10 MiB")
112
+ return {
113
+ "path": os.fspath(output),
114
+ "name": manifest["name"],
115
+ "version": manifest["version"],
116
+ "digest": bytes_digest(package_bytes),
117
+ "size_bytes": len(package_bytes),
118
+ "media_type": "application/vnd.geyser.extension+zip",
119
+ }
120
+
121
+
122
+ def inspect_archive(path: Path) -> dict[str, Any]:
123
+ path = path.expanduser().resolve()
124
+ if path.stat().st_size > MAX_PACKAGE_BYTES:
125
+ raise ValueError("archive exceeds 10 MiB")
126
+ total = 0
127
+ names: set[str] = set()
128
+ with zipfile.ZipFile(path) as archive:
129
+ for info in archive.infolist():
130
+ pure = PurePosixPath(info.filename)
131
+ if pure.is_absolute() or ".." in pure.parts or "" in pure.parts:
132
+ raise ValueError("archive contains path traversal")
133
+ if info.filename in names:
134
+ raise ValueError("archive contains duplicate paths")
135
+ names.add(info.filename)
136
+ mode = info.external_attr >> 16
137
+ if stat.S_ISLNK(mode):
138
+ raise ValueError("archive contains a symbolic link")
139
+ total += info.file_size
140
+ if info.file_size > MAX_FILE_BYTES or total > MAX_PACKAGE_BYTES:
141
+ raise ValueError("archive exceeds unpacked safety bounds")
142
+ return {"path": os.fspath(path), "digest": bytes_digest(path.read_bytes()), "files": len(names)}
143
+
144
+
145
+ __all__ = [
146
+ "ExtensionManifest",
147
+ "inspect_archive",
148
+ "package_extension",
149
+ "test_extension",
150
+ "validate_extension",
151
+ ]
@@ -0,0 +1,31 @@
1
+ """Stable human and machine-readable command output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from pydantic import BaseModel
9
+
10
+
11
+ def serializable(value: Any) -> Any:
12
+ if isinstance(value, BaseModel):
13
+ return value.model_dump(mode="json")
14
+ if isinstance(value, dict):
15
+ return {key: serializable(child) for key, child in value.items()}
16
+ if isinstance(value, (list, tuple)):
17
+ return [serializable(child) for child in value]
18
+ return value
19
+
20
+
21
+ def emit(value: Any, *, machine: bool) -> None:
22
+ normalized = serializable(value)
23
+ if machine:
24
+ print(json.dumps(normalized, sort_keys=True, separators=(",", ":"), ensure_ascii=False))
25
+ elif isinstance(normalized, str):
26
+ print(normalized)
27
+ else:
28
+ print(json.dumps(normalized, indent=2, sort_keys=True, ensure_ascii=False))
29
+
30
+
31
+ __all__ = ["emit", "serializable"]
@@ -0,0 +1,120 @@
1
+ """Safe extension scaffolds used by ``geyser init``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from pathlib import Path
8
+
9
+ KINDS = ("skill", "connector", "tool", "evaluator", "model-profile", "agent-bundle")
10
+ _NAME = re.compile(r"^[a-z][a-z0-9-]{1,63}$")
11
+
12
+
13
+ def _json(value: object) -> str:
14
+ return json.dumps(value, indent=2, sort_keys=True, ensure_ascii=False) + "\n"
15
+
16
+
17
+ def scaffold_files(kind: str, name: str) -> dict[str, str]:
18
+ common = {
19
+ "geyser-package.json": _json({
20
+ "schema_version": 1,
21
+ "kind": kind,
22
+ "name": name,
23
+ "version": "0.1.0",
24
+ "permissions": [],
25
+ }),
26
+ "README.md": (
27
+ f"# {name}\n\nA credential-free `{kind}` extension for Geyser Open. "
28
+ "Declared permissions do not grant authority; server policy remains authoritative.\n"
29
+ ),
30
+ "evals/cases.json": _json({
31
+ "schema_version": 1,
32
+ "frozen": True,
33
+ "cases": [
34
+ {"case_id": "bounded-success-1", "critical": False, "expected": "success"},
35
+ {
36
+ "case_id": "critical-denial-1",
37
+ "critical": True,
38
+ "expected": "deny_without_explicit_authority",
39
+ },
40
+ ],
41
+ }),
42
+ }
43
+ if kind == "skill":
44
+ return {**common, "SKILL.md": (
45
+ f"# {name}\n\n## Purpose\n\nDescribe one bounded capability.\n\n"
46
+ "## Authority\n\nThis skill never grants tools, credentials, or approval.\n"
47
+ )}
48
+ if kind == "connector":
49
+ return {**common, "connector.json": _json({
50
+ "schema_version": 1,
51
+ "connector_id": name,
52
+ "directions": ["inbound"],
53
+ "identity_link_required": True,
54
+ "content_storage": "customer_cell_reference_only",
55
+ "delivery": {"deduplicate": True, "max_loop_hops": 8},
56
+ "permissions": {"event_types": [], "network_origins": []},
57
+ "revocable": True,
58
+ })}
59
+ if kind == "tool":
60
+ return {**common, "tool.json": _json({
61
+ "schema_version": 1,
62
+ "tool_name": name,
63
+ "description": "Describe the bounded effect without promising authority.",
64
+ "input_schema": {"type": "object", "additionalProperties": False},
65
+ "effect_class": "read_only",
66
+ "approval_posture": "policy_decides",
67
+ "idempotency_required": True,
68
+ })}
69
+ if kind == "evaluator":
70
+ return {**common, "evaluator.json": _json({
71
+ "schema_version": 1,
72
+ "evaluator_id": name,
73
+ "score_range": [0, 1],
74
+ "critical_slices": ["authorization", "privacy", "false_success"],
75
+ "content_capture": False,
76
+ })}
77
+ if kind == "model-profile":
78
+ return {**common, "model-profile.json": _json({
79
+ "schema_version": 1,
80
+ "profile_id": name,
81
+ "qualified": False,
82
+ "qualification_ref": "",
83
+ "custody": "declare-before-use",
84
+ "fallback": "none",
85
+ "task_performance_cards": {},
86
+ })}
87
+ return {**common, "agent-bundle-selection.json": _json({
88
+ "schema_version": 1,
89
+ "name": name,
90
+ "components": [
91
+ "identity_persona",
92
+ "skills",
93
+ "policy_references",
94
+ "brain_export",
95
+ "history_index",
96
+ "artifact_manifest",
97
+ ],
98
+ "owner_review_required": True,
99
+ "include_credentials": False,
100
+ "include_provider_sessions": False,
101
+ "include_hidden_reasoning": False,
102
+ })}
103
+
104
+
105
+ def scaffold(kind: str, name: str, output: Path) -> Path:
106
+ if kind not in KINDS:
107
+ raise ValueError("unknown scaffold kind")
108
+ if _NAME.fullmatch(name) is None:
109
+ raise ValueError("name must be 2-64 lowercase letters, numbers, and hyphens")
110
+ root = output.expanduser().resolve() / name
111
+ if root.exists() and any(root.iterdir()):
112
+ raise FileExistsError(f"refusing to overwrite non-empty scaffold: {root}")
113
+ for relative, body in scaffold_files(kind, name).items():
114
+ target = root / relative
115
+ target.parent.mkdir(parents=True, exist_ok=True)
116
+ target.write_text(body, encoding="utf-8")
117
+ return root
118
+
119
+
120
+ __all__ = ["KINDS", "scaffold", "scaffold_files"]
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import httpx
6
+ import pytest
7
+ from geyser_cli.auth import login_device
8
+ from geyser_cli.credentials import StoredCredential
9
+
10
+
11
+ class MemoryStore:
12
+ credential: StoredCredential | None = None
13
+
14
+ def save(self, credential: StoredCredential) -> str:
15
+ self.credential = credential
16
+ return "test-keychain"
17
+
18
+
19
+ def test_device_flow(monkeypatch: pytest.MonkeyPatch) -> None:
20
+ responses = iter([
21
+ httpx.Response(200, json={
22
+ "device_code": "device-secret",
23
+ "user_code": "ABCD-EFGH",
24
+ "verification_uri": "https://auth.example/device",
25
+ "verification_uri_complete": "https://auth.example/device?code=ABCD-EFGH",
26
+ "expires_in": 60,
27
+ "interval": 1,
28
+ }),
29
+ httpx.Response(400, json={"error": "authorization_pending"}),
30
+ httpx.Response(200, json={
31
+ "access_token": "access-secret",
32
+ "token_type": "Bearer",
33
+ "expires_in": 3600,
34
+ "scope": "development:read runs:read",
35
+ "customer_id": 1,
36
+ "project_id": "project-1",
37
+ }),
38
+ ])
39
+
40
+ class Client:
41
+ def __init__(self, **_kwargs: Any) -> None:
42
+ pass
43
+
44
+ def __enter__(self) -> Client:
45
+ return self
46
+
47
+ def __exit__(self, *_args: object) -> None:
48
+ pass
49
+
50
+ def post(self, *_args: object, **_kwargs: object) -> httpx.Response:
51
+ return next(responses)
52
+
53
+ monkeypatch.setattr("geyser_cli.auth.httpx.Client", Client)
54
+ monkeypatch.setattr("geyser_cli.auth.time.sleep", lambda _seconds: None)
55
+ store = MemoryStore()
56
+ notifications: list[str] = []
57
+ value = login_device(
58
+ "https://api.example",
59
+ store, # type: ignore[arg-type]
60
+ scopes=["development:read"],
61
+ open_browser=False,
62
+ notify=lambda device: notifications.append(device.user_code),
63
+ )
64
+ assert value["storage"] == "test-keychain"
65
+ assert store.credential is not None
66
+ assert store.credential.access_token == "access-secret" # noqa: S105 - test fixture
67
+ assert notifications == ["ABCD-EFGH"]
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import stat
5
+ import zipfile
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+ from geyser_cli.__main__ import main
10
+ from geyser_cli.credentials import CredentialStore, StoredCredential
11
+ from geyser_cli.extensions import inspect_archive, package_extension
12
+
13
+
14
+ def test_complete_local_workflow(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
15
+ assert main(["--json", "init", "tool", "careful-search", "--output", str(tmp_path)]) == 0
16
+ root = tmp_path / "careful-search"
17
+ created = json.loads(capsys.readouterr().out)
18
+ assert created["created"] is True
19
+ assert main(["--json", "validate", str(root)]) == 0
20
+ assert json.loads(capsys.readouterr().out)["valid"] is True
21
+ assert main(["--json", "test", str(root)]) == 0
22
+ assert json.loads(capsys.readouterr().out)["passed"] == 2
23
+ assert main(["--json", "dev", str(root)]) == 0
24
+ assert json.loads(capsys.readouterr().out)["network_used"] is False
25
+ assert main(["--json", "package", str(root)]) == 0
26
+ packaged = json.loads(capsys.readouterr().out)
27
+ archive = Path(packaged["path"])
28
+ first = archive.read_bytes()
29
+ assert package_extension(root)["digest"] == packaged["digest"]
30
+ assert archive.read_bytes() == first
31
+ assert inspect_archive(archive)["files"] >= 4
32
+
33
+
34
+ def test_outcome_cli_and_errors(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
35
+ contract = tmp_path / "contract.json"
36
+ result = tmp_path / "result.json"
37
+ contract.write_text(json.dumps({
38
+ "schema_version": 1,
39
+ "schema_ref": "example:answer:v1",
40
+ "json_schema": {
41
+ "type": "object", "properties": {"answer": {"type": "string"}},
42
+ "required": ["answer"], "additionalProperties": False,
43
+ },
44
+ }))
45
+ result.write_text('{"answer":"yes"}')
46
+ assert main(["--json", "validate-outcome", str(contract), str(result)]) == 0
47
+ assert json.loads(capsys.readouterr().out)["valid"] is True
48
+ result.write_text('{"wrong":true}')
49
+ assert main(["--json", "validate-outcome", str(contract), str(result)]) == 2
50
+ assert "OutcomeValidationError" in capsys.readouterr().out
51
+ assert main(["--json", "init", "tool", "BAD", "--output", str(tmp_path)]) == 2
52
+
53
+
54
+ def test_archive_traversal_symlink_and_bounds(tmp_path: Path) -> None:
55
+ traversal = tmp_path / "traversal.zip"
56
+ with zipfile.ZipFile(traversal, "w") as archive:
57
+ archive.writestr("../escape", "bad")
58
+ with pytest.raises(ValueError, match="traversal"):
59
+ inspect_archive(traversal)
60
+ symlink = tmp_path / "symlink.zip"
61
+ with zipfile.ZipFile(symlink, "w") as archive:
62
+ info = zipfile.ZipInfo("link")
63
+ info.external_attr = (stat.S_IFLNK | 0o777) << 16
64
+ archive.writestr(info, "target")
65
+ with pytest.raises(ValueError, match="symbolic link"):
66
+ inspect_archive(symlink)
67
+
68
+
69
+ def test_explicit_restricted_file_credential_fallback(
70
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
71
+ ) -> None:
72
+ def unavailable(*_args: object) -> None:
73
+ raise RuntimeError("no keychain")
74
+
75
+ monkeypatch.setattr("geyser_cli.credentials.keyring.set_password", unavailable)
76
+ monkeypatch.setattr("geyser_cli.credentials.keyring.get_password", unavailable)
77
+ monkeypatch.setattr("geyser_cli.credentials.keyring.delete_password", unavailable)
78
+ denied = CredentialStore("test", allow_file_fallback=False)
79
+ denied.path = tmp_path / "credentials.json"
80
+ with pytest.raises(RuntimeError, match="opt in"):
81
+ denied.save(StoredCredential(access_token="secret")) # noqa: S106 - test fixture
82
+ store = CredentialStore("test", allow_file_fallback=True)
83
+ store.path = tmp_path / "credentials.json"
84
+ assert store.save(StoredCredential(access_token="secret")) == "restricted-file" # noqa: S106
85
+ assert stat.S_IMODE(store.path.stat().st_mode) == 0o600
86
+ assert store.load() is not None
87
+ assert store.load().access_token == "secret" # type: ignore[union-attr] # noqa: S105
88
+ assert store.delete() is True
89
+ assert not store.path.exists()
90
+
91
+
92
+ def test_version_is_machine_readable(capsys: pytest.CaptureFixture[str]) -> None:
93
+ assert main(["--json", "version"]) == 0
94
+ assert json.loads(capsys.readouterr().out)["geyser_open"] == "0.1.0b4"
@@ -0,0 +1,127 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import Iterator
5
+ from pathlib import Path
6
+ from types import SimpleNamespace
7
+ from typing import Any
8
+
9
+ import pytest
10
+ from geyser_cli import __main__ as cli
11
+ from geyser_cli.extensions import package_extension
12
+ from geyser_cli.scaffolds import scaffold
13
+
14
+ SHA = "sha256:" + "a" * 64
15
+
16
+
17
+ class FakeClient:
18
+ def __enter__(self) -> FakeClient:
19
+ return self
20
+
21
+ def __exit__(self, *_args: object) -> None:
22
+ pass
23
+
24
+ def list_packages(self) -> dict[str, Any]:
25
+ return {"data": []}
26
+
27
+ def capabilities(self, *, agent_name: str) -> dict[str, Any]:
28
+ return {"agent_name": agent_name, "capabilities": {}}
29
+
30
+ def list_runs(self, *, customer: bool = False) -> dict[str, Any]:
31
+ return {"data": [], "customer": customer}
32
+
33
+ def get_run(self, run_id: str, *, customer: bool = False) -> dict[str, Any]:
34
+ return {"run_id": run_id, "customer": customer}
35
+
36
+ def trace(self, run_id: str, *, customer: bool = False) -> dict[str, Any]:
37
+ return {"trace_id": run_id, "customer": customer}
38
+
39
+ def watch_events(self, run_id: str, *, customer: bool = False) -> Iterator[dict[str, Any]]:
40
+ yield {"run_id": run_id, "customer": customer}
41
+
42
+ def cancel_run(self, run_id: str, request: Any) -> dict[str, Any]:
43
+ return {"run_id": run_id, "cancellation_id": request.cancellation_id}
44
+
45
+ def fork(self, run_id: str, request: Any) -> dict[str, Any]:
46
+ return {"run_id": run_id, "fork_id": request.fork_id}
47
+
48
+ def list_approvals(self) -> dict[str, Any]:
49
+ return {"data": []}
50
+
51
+ def get_approval(self, approval_id: str) -> dict[str, Any]:
52
+ return {"approval_id": approval_id}
53
+
54
+ def decide_approval(self, run_id: str, approval_id: str, decision: Any) -> dict[str, Any]:
55
+ return {"run_id": run_id, "approval_id": approval_id, "decision": decision.decision}
56
+
57
+ def upload_package(self, package: Any, *, idempotency_key: str) -> dict[str, Any]:
58
+ return {"name": package.name, "digest": idempotency_key, "stage": "staging"}
59
+
60
+ def promote_package(self, package_id: str, promotion: Any) -> dict[str, Any]:
61
+ return {"package_id": package_id, "target": promotion.target}
62
+
63
+
64
+ @pytest.mark.parametrize("arguments", [
65
+ ["status"],
66
+ ["capabilities", "--agent", "Ada"],
67
+ ["runs", "list", "--customer"],
68
+ ["runs", "get", "run-1"],
69
+ ["runs", "watch", "run-1"],
70
+ ["runs", "trace", "run-1", "--customer"],
71
+ ["runs", "stop", "run-1", "--expected-sequence", "2", "--yes"],
72
+ ["runs", "fork", "run-1", "--child-run-id", "run-2", "--expected-sequence", "2", "--yes"],
73
+ ["approvals", "list"],
74
+ ["approvals", "get", "approval-1"],
75
+ [
76
+ "approvals", "decide", "run-1", "approval-1", "approve",
77
+ "--expected-sequence", "2", "--binding-digest", SHA,
78
+ "--reason-code", "reviewed", "--yes",
79
+ ],
80
+ ["promote", "package-1", "--digest", SHA, "--canary", "--yes"],
81
+ ])
82
+ def test_network_commands(
83
+ arguments: list[str], monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
84
+ ) -> None:
85
+ monkeypatch.setattr(cli, "_client", lambda _args: FakeClient())
86
+ assert cli.main(["--json", *arguments]) == 0
87
+ output = capsys.readouterr().out.strip().splitlines()
88
+ assert json.loads(output[-1])
89
+
90
+
91
+ def test_publish_and_sign(
92
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
93
+ ) -> None:
94
+ root = scaffold("tool", "sample-tool", tmp_path)
95
+ archive = Path(package_extension(root)["path"])
96
+ monkeypatch.setattr(cli, "_client", lambda _args: FakeClient())
97
+ assert cli.main(["--json", "publish", str(archive), "--stage", "--yes"]) == 0
98
+ assert json.loads(capsys.readouterr().out.strip().splitlines()[-1])["stage"] == "staging"
99
+ monkeypatch.setattr(cli.shutil, "which", lambda _name: "/usr/bin/true")
100
+ monkeypatch.setattr(
101
+ cli.subprocess,
102
+ "run",
103
+ lambda *_args, **_kwargs: SimpleNamespace(returncode=0),
104
+ )
105
+ assert cli.main(["--json", "sign", str(archive)]) == 0
106
+
107
+
108
+ def test_doctor_and_confirmation_cancel(
109
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
110
+ ) -> None:
111
+ monkeypatch.setattr(cli, "_store", lambda _args: SimpleNamespace(load=lambda: None))
112
+ monkeypatch.setattr(cli.httpx, "get", lambda *_args, **_kwargs: SimpleNamespace(
113
+ status_code=200, json=lambda: {"info": {"version": "2026-08-24"}}
114
+ ))
115
+ assert cli.main(["--json", "doctor"]) == 0
116
+ assert json.loads(capsys.readouterr().out)["api_reachable"] is True
117
+ monkeypatch.setattr(cli, "_client", lambda _args: FakeClient())
118
+ monkeypatch.setattr(cli.sys.stdin, "isatty", lambda: False)
119
+ assert cli.main(["--json", "runs", "stop", "run-1", "--expected-sequence", "2"]) == 2
120
+
121
+
122
+ @pytest.mark.parametrize(
123
+ "kind", ["skill", "connector", "evaluator", "model-profile", "agent-bundle"]
124
+ )
125
+ def test_all_scaffold_kinds(kind: str, tmp_path: Path) -> None:
126
+ root = scaffold(kind, f"sample-{kind}", tmp_path)
127
+ assert (root / "geyser-package.json").is_file()