pollaris-cli 0.1.0__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,75 @@
1
+ Metadata-Version: 2.4
2
+ Name: pollaris-cli
3
+ Version: 0.1.0
4
+ Summary: Agent-friendly Pollaris CLI and MCP bridge
5
+ Project-URL: Repository, https://github.com/devsapariya94/Pollaris
6
+ Project-URL: Documentation, https://github.com/devsapariya94/Pollaris/blob/main/cli/README.md
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: httpx>=0.27
10
+ Requires-Dist: keyring>=25
11
+ Requires-Dist: cryptography>=43
12
+ Provides-Extra: mcp
13
+ Requires-Dist: mcp<3,>=2; extra == "mcp"
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest>=8; extra == "dev"
16
+
17
+ # Pollaris CLI
18
+
19
+ Create and publish Pollaris forms from terminal-capable agents. The CLI starts a
20
+ browser authorization flow when needed, preserves the original operation, and resumes
21
+ it after the user grants access. It returns an authorization URL; it does not open
22
+ a browser automatically.
23
+
24
+ Development install from the repository root:
25
+
26
+ ```bash
27
+ uv tool install --editable ./cli
28
+ ```
29
+
30
+ For package publication and production testing, see [publishing](PUBLISHING.md).
31
+
32
+ For the optional local MCP bridge, include its dependencies:
33
+
34
+ ```bash
35
+ uv tool install --editable './cli[mcp]'
36
+ ```
37
+
38
+ Point the CLI at an agent-enabled deployment (see [server setup](../docs/mcp/agent-access.md)):
39
+
40
+ ```bash
41
+ export POLLARIS_API_URL=https://api.example.com
42
+ pollaris capabilities --json
43
+ ```
44
+
45
+ Create a form:
46
+
47
+ ```bash
48
+ pollaris forms create \
49
+ --prompt "Create a short library feedback form" \
50
+ --publish \
51
+ --wait \
52
+ --json
53
+ ```
54
+
55
+ When the command returns `requires_authorization`, open `authorization.url` and
56
+ run the returned `next_action.command`. The finished result contains
57
+ `form.public_url`.
58
+
59
+ Creation publishes by default. Use `--draft-only` to create an editable draft.
60
+ `--wait` polls for completion up to `--timeout` seconds (default: 45); a pending
61
+ job can be checked with `pollaris jobs get JOB_ID --json` or
62
+ `pollaris jobs wait JOB_ID --json`.
63
+
64
+ To update or publish an existing form, inspect it first with
65
+ `pollaris forms get FORM_ID --json`, then pass its current `draft_revision` as
66
+ `--expected-revision` to `forms save` or `forms publish`. Saving also requires
67
+ `--definition path/to/definition.json`.
68
+
69
+ Use `pollaris --help` and each command's `--help` for options. Credentials use an
70
+ OS keychain, or encrypted file storage configured by `POLLARIS_CLI_SECRET_KEY`
71
+ (a Fernet key) on headless hosts. Local state defaults to `~/.config/pollaris`;
72
+ `POLLARIS_CLI_HOME` overrides it and `--profile` selects a separate profile.
73
+
74
+ See [OAuth agent access](../docs/mcp/agent-access.md), [MCP tools](../docs/mcp/tools.md),
75
+ and the [integration index](../docs/README.md) for deployment and connection guides.
@@ -0,0 +1,59 @@
1
+ # Pollaris CLI
2
+
3
+ Create and publish Pollaris forms from terminal-capable agents. The CLI starts a
4
+ browser authorization flow when needed, preserves the original operation, and resumes
5
+ it after the user grants access. It returns an authorization URL; it does not open
6
+ a browser automatically.
7
+
8
+ Development install from the repository root:
9
+
10
+ ```bash
11
+ uv tool install --editable ./cli
12
+ ```
13
+
14
+ For package publication and production testing, see [publishing](PUBLISHING.md).
15
+
16
+ For the optional local MCP bridge, include its dependencies:
17
+
18
+ ```bash
19
+ uv tool install --editable './cli[mcp]'
20
+ ```
21
+
22
+ Point the CLI at an agent-enabled deployment (see [server setup](../docs/mcp/agent-access.md)):
23
+
24
+ ```bash
25
+ export POLLARIS_API_URL=https://api.example.com
26
+ pollaris capabilities --json
27
+ ```
28
+
29
+ Create a form:
30
+
31
+ ```bash
32
+ pollaris forms create \
33
+ --prompt "Create a short library feedback form" \
34
+ --publish \
35
+ --wait \
36
+ --json
37
+ ```
38
+
39
+ When the command returns `requires_authorization`, open `authorization.url` and
40
+ run the returned `next_action.command`. The finished result contains
41
+ `form.public_url`.
42
+
43
+ Creation publishes by default. Use `--draft-only` to create an editable draft.
44
+ `--wait` polls for completion up to `--timeout` seconds (default: 45); a pending
45
+ job can be checked with `pollaris jobs get JOB_ID --json` or
46
+ `pollaris jobs wait JOB_ID --json`.
47
+
48
+ To update or publish an existing form, inspect it first with
49
+ `pollaris forms get FORM_ID --json`, then pass its current `draft_revision` as
50
+ `--expected-revision` to `forms save` or `forms publish`. Saving also requires
51
+ `--definition path/to/definition.json`.
52
+
53
+ Use `pollaris --help` and each command's `--help` for options. Credentials use an
54
+ OS keychain, or encrypted file storage configured by `POLLARIS_CLI_SECRET_KEY`
55
+ (a Fernet key) on headless hosts. Local state defaults to `~/.config/pollaris`;
56
+ `POLLARIS_CLI_HOME` overrides it and `--profile` selects a separate profile.
57
+
58
+ See [OAuth agent access](../docs/mcp/agent-access.md), [MCP tools](../docs/mcp/tools.md),
59
+ and the [integration index](../docs/README.md) for deployment and connection guides.
@@ -0,0 +1,28 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pollaris-cli"
7
+ version = "0.1.0"
8
+ description = "Agent-friendly Pollaris CLI and MCP bridge"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ dependencies = ["httpx>=0.27", "keyring>=25", "cryptography>=43"]
12
+
13
+ [project.optional-dependencies]
14
+ mcp = ["mcp>=2,<3"]
15
+ dev = ["pytest>=8"]
16
+
17
+ [project.urls]
18
+ Repository = "https://github.com/devsapariya94/Pollaris"
19
+ Documentation = "https://github.com/devsapariya94/Pollaris/blob/main/cli/README.md"
20
+
21
+ [project.scripts]
22
+ pollaris = "pollaris_cli.main:main"
23
+
24
+ [tool.setuptools.packages.find]
25
+ where = ["src"]
26
+
27
+ [tool.pytest.ini_options]
28
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """Pollaris client. No server or database imports."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,64 @@
1
+ """Transparent stdio-to-remote-MCP bridge using a connection-specific grant."""
2
+
3
+ import anyio
4
+ from mcp import ClientSession
5
+ from mcp.client.streamable_http import streamable_http_client
6
+ from mcp.server import Server
7
+ from mcp.server.stdio import stdio_server
8
+ from mcp.shared._httpx_utils import create_mcp_http_client
9
+ from mcp.types import CallToolRequestParams, PaginatedRequestParams
10
+
11
+ from .client import Client
12
+
13
+
14
+ async def serve(store, api_url=None):
15
+ client = Client(store, api_url)
16
+ capabilities = await anyio.to_thread.run_sync(client.discover)
17
+ mcp_url = capabilities["mcp_url"]
18
+
19
+ async def remote():
20
+ token = await anyio.to_thread.run_sync(client.access_token)
21
+ if token is None:
22
+ raise RuntimeError(
23
+ "Pollaris MCP authorization is required. Run pollaris mcp connect first."
24
+ )
25
+ http = create_mcp_http_client(headers={"Authorization": f"Bearer {token}"})
26
+ transport = streamable_http_client(mcp_url, http_client=http)
27
+ streams = await transport.__aenter__()
28
+ session = ClientSession(streams[0], streams[1])
29
+ await session.__aenter__()
30
+ await session.initialize()
31
+ return transport, session, http
32
+
33
+ async def list_tools(_context, params: PaginatedRequestParams | None):
34
+ transport, session, http = await remote()
35
+ try:
36
+ return await session.list_tools(params=params)
37
+ finally:
38
+ await session.__aexit__(None, None, None)
39
+ await transport.__aexit__(None, None, None)
40
+ await http.aclose()
41
+
42
+ async def call_tool(_context, params: CallToolRequestParams):
43
+ transport, session, http = await remote()
44
+ try:
45
+ return await session.call_tool(
46
+ params.name,
47
+ params.arguments,
48
+ input_responses=params.input_responses,
49
+ request_state=params.request_state,
50
+ )
51
+ finally:
52
+ await session.__aexit__(None, None, None)
53
+ await transport.__aexit__(None, None, None)
54
+ await http.aclose()
55
+
56
+ server = Server(
57
+ "pollaris-cli-bridge",
58
+ version="0.1.0",
59
+ instructions="Pollaris remote tools through a local connection grant.",
60
+ on_list_tools=list_tools,
61
+ on_call_tool=call_tool,
62
+ )
63
+ async with stdio_server() as streams:
64
+ await server.run(streams[0], streams[1], server.create_initialization_options())
@@ -0,0 +1,236 @@
1
+ """HTTP/auth continuation without coupling to the Pollaris application package."""
2
+
3
+ import time
4
+ import uuid
5
+ from urllib.parse import urlsplit
6
+
7
+ import httpx
8
+
9
+ from .storage import LocalError
10
+
11
+ SCOPES = "pollaris.forms.read pollaris.forms.write pollaris.forms.generate pollaris.forms.publish"
12
+ CLIENT_ID = "pollaris-cli"
13
+
14
+
15
+ class APIError(Exception):
16
+ def __init__(self, code, message, status=400):
17
+ self.code, self.message, self.status = code, message, status
18
+ super().__init__(message)
19
+
20
+
21
+ def validate_origin(url):
22
+ parsed = urlsplit(url)
23
+ if (not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment
24
+ or (parsed.scheme != "https" and not (
25
+ parsed.scheme == "http" and parsed.hostname in {"localhost", "127.0.0.1", "::1"}
26
+ ))):
27
+ raise LocalError("Use an HTTPS deployment URL (HTTP is allowed only for loopback development)")
28
+ return url.rstrip("/")
29
+
30
+
31
+ class Client:
32
+ def __init__(self, store, api_url=None, http=None):
33
+ self.store = store
34
+ config = store.read("config", {})
35
+ self.api_url = validate_origin(api_url or config.get("api_url") or "http://localhost:8000")
36
+ if config.get("api_url") and config["api_url"] != self.api_url:
37
+ raise LocalError("Deployment differs from this profile; use a separate --profile")
38
+ self.http = http or httpx.Client(timeout=30, follow_redirects=False)
39
+ self.capabilities = None
40
+
41
+ def raw(self, method, url, **kwargs):
42
+ response = self.http.request(method, url, **kwargs)
43
+ try:
44
+ data = response.json()
45
+ except ValueError as exc:
46
+ raise APIError("invalid_response", "Server returned an invalid response", response.status_code) from exc
47
+ if not response.is_success:
48
+ code = data.get("error", "request_failed") if isinstance(data, dict) else "request_failed"
49
+ message = data.get("error_description", "Pollaris request failed") if isinstance(data, dict) else "Pollaris request failed"
50
+ raise APIError(code if isinstance(code, str) else "request_failed", message, response.status_code)
51
+ return data
52
+
53
+ def discover(self):
54
+ if self.capabilities is None:
55
+ data = self.raw("GET", f"{self.api_url}/v1/agent/capabilities")
56
+ validate_origin(data["issuer"])
57
+ validate_origin(data["mcp_url"])
58
+ # Issuer comes from the explicitly chosen trusted deployment, never a model argument.
59
+ self.capabilities = data
60
+ return self.capabilities
61
+
62
+ def remember_tokens(self, tokens):
63
+ tokens["expires_at"] = time.time() + tokens["expires_in"]
64
+ self.store.secret("tokens", tokens)
65
+ self.store.write("config", {"api_url": self.api_url, "resource": tokens["resource"],
66
+ "workspace_id": tokens["workspace_id"],
67
+ "issuer": self.discover()["issuer"],
68
+ "connection_id": tokens["connection_id"]})
69
+
70
+ def access_token(self):
71
+ tokens = self.store.secret("tokens")
72
+ if not tokens:
73
+ return None
74
+ if tokens["expires_at"] <= time.time() + 30:
75
+ config = self.store.read("config", {})
76
+ try:
77
+ tokens = self.raw("POST", f"{config['issuer']}/oauth/token", data={
78
+ "grant_type": "refresh_token", "client_id": CLIENT_ID,
79
+ "refresh_token": tokens["refresh_token"], "resource": tokens["resource"],
80
+ })
81
+ self.remember_tokens(tokens)
82
+ except APIError as exc:
83
+ if exc.status < 500 and exc.status != 429:
84
+ self.store.secret("tokens", None)
85
+ raise
86
+ return tokens["access_token"]
87
+
88
+ def request(self, method, path, **kwargs):
89
+ token = self.access_token()
90
+ if token is None:
91
+ raise APIError("invalid_token", "Authorization required", 401)
92
+ headers = kwargs.pop("headers", {})
93
+ return self.raw(method, self.api_url + path,
94
+ headers={**headers, "Authorization": f"Bearer {token}"}, **kwargs)
95
+
96
+ def operation(self, kind, payload, *, key=None, resource="api", extras=None):
97
+ if key is not None and not 8 <= len(key) <= 100:
98
+ raise LocalError("Idempotency key must contain 8–100 characters")
99
+ # Explicit same key maps to the same local operation, including auth continuation.
100
+ operation_id = str(uuid.uuid5(uuid.NAMESPACE_URL, f"{self.api_url}:{self.store.profile}:{key}")) if key else str(uuid.uuid4())
101
+ existing = self.store.read(f"operations/{operation_id}")
102
+ if existing:
103
+ if (existing["kind"] != kind or existing["payload"] != payload
104
+ or existing["resource"] != resource
105
+ or any(existing.get(name) != value for name, value in (extras or {}).items())):
106
+ raise APIError("idempotency_conflict", "Key was used with different input", 409)
107
+ return existing
108
+ value = {"operation_id": operation_id, "key": key or operation_id, "kind": kind,
109
+ "payload": payload, "resource": resource, "status": "prepared",
110
+ "api_url": self.api_url, "created_at": time.time(), **(extras or {})}
111
+ self.store.save_operation(value)
112
+ self.store.write("config", {**self.store.read("config", {}), "api_url": self.api_url})
113
+ return value
114
+
115
+ def start_auth(self, operation):
116
+ info = self.discover()
117
+ resource = info["mcp_url"].rstrip("/") if operation["resource"] == "mcp" else info["resource"]
118
+ tokens = self.store.secret("tokens")
119
+ if tokens and tokens["resource"] != resource:
120
+ raise LocalError("This profile belongs to another resource; use a separate connection profile")
121
+ auth = self.raw("POST", f"{info['issuer'].rstrip('/')}/oauth/device_authorization", data={
122
+ "client_id": CLIENT_ID, "scope": SCOPES, "resource": resource,
123
+ "workspace_id": operation["payload"].get("workspace_id") or "",
124
+ "connection_name": f"Pollaris {self.store.profile}",
125
+ })
126
+ auth.update({"expires_at": time.time() + auth["expires_in"],
127
+ "next_poll": time.time() + auth["interval"], "issuer": info["issuer"]})
128
+ self.store.secret(f"device:{operation['operation_id']}", auth)
129
+ operation["status"] = "awaiting_authorization"
130
+ self.store.save_operation(operation)
131
+ return auth
132
+
133
+ def authorization(self, operation):
134
+ token = None
135
+ try:
136
+ token = self.access_token()
137
+ except APIError as exc:
138
+ if exc.code not in {"invalid_grant", "invalid_token", "access_denied"}:
139
+ raise
140
+ if token:
141
+ return None
142
+ auth = self.store.secret(f"device:{operation['operation_id']}")
143
+ if not auth or auth["expires_at"] <= time.time():
144
+ auth = self.start_auth(operation)
145
+ if time.time() >= auth["next_poll"]:
146
+ try:
147
+ tokens = self.raw("POST", f"{auth['issuer']}/oauth/token", data={
148
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
149
+ "client_id": CLIENT_ID, "device_code": auth["device_code"],
150
+ })
151
+ self.remember_tokens(tokens)
152
+ self.store.secret(f"device:{operation['operation_id']}", None)
153
+ operation["status"] = "authorized"
154
+ self.store.save_operation(operation)
155
+ return None
156
+ except APIError as exc:
157
+ if exc.code == "slow_down":
158
+ auth["interval"] += 5
159
+ elif exc.code == "access_denied":
160
+ operation["status"] = "denied"
161
+ self.store.save_operation(operation)
162
+ self.store.secret(f"device:{operation['operation_id']}", None)
163
+ raise
164
+ elif exc.code == "expired_token":
165
+ auth = self.start_auth(operation)
166
+ elif exc.code != "authorization_pending":
167
+ raise
168
+ auth["next_poll"] = time.time() + auth["interval"]
169
+ self.store.secret(f"device:{operation['operation_id']}", auth)
170
+ return {"status": "requires_authorization", "operation_id": operation["operation_id"],
171
+ "authorization": {"url": auth["verification_uri_complete"],
172
+ "user_code": auth["user_code"], "expires_at": auth["expires_at"]},
173
+ "next_action": {"command": f"pollaris operations resume {operation['operation_id']} --profile {self.store.profile} --wait --timeout 45 --json"}}
174
+
175
+ def advance(self, operation):
176
+ if operation.get("result"):
177
+ return operation["result"]
178
+ if operation["api_url"] != self.api_url:
179
+ raise LocalError("Operation belongs to another deployment")
180
+ if operation["status"] == "denied":
181
+ raise APIError("access_denied", "Authorization was declined; start a new request")
182
+ auth = self.authorization(operation)
183
+ if auth:
184
+ return auth
185
+ kind = operation["kind"]
186
+ if kind in {"login", "connect"}:
187
+ result = {"status": "completed", "connection": self.store.read("config")}
188
+ if kind == "connect":
189
+ from .connections import install
190
+ result.update(install(self.store, operation))
191
+ elif kind == "create":
192
+ if not operation.get("job_id"):
193
+ job = self.request("POST", "/v1/agent/form-jobs", json=operation["payload"],
194
+ headers={"Idempotency-Key": operation["key"]})
195
+ operation["job_id"] = job["job_id"]
196
+ operation["status"] = "submitted"
197
+ self.store.save_operation(operation)
198
+ job = self.request("GET", f"/v1/agent/jobs/{operation['job_id']}")
199
+ if job["status"] == "requires_authorization":
200
+ # Refresh cannot restore a revoked grant. Resume after a new consent.
201
+ self.store.secret("tokens", None)
202
+ operation["needs_job_resume"] = True
203
+ self.store.save_operation(operation)
204
+ return self.authorization(operation)
205
+ if operation.pop("needs_job_resume", False):
206
+ job = self.request("POST", f"/v1/agent/jobs/{operation['job_id']}/resume")
207
+ self.store.save_operation(operation)
208
+ if job["status"] == "failed":
209
+ raise APIError(job["error"]["code"], job["error"]["message"], 422)
210
+ if job["status"] != "completed":
211
+ return {**job, "status": "pending", "operation_id": operation["operation_id"],
212
+ "next_action": {"command": f"pollaris operations resume {operation['operation_id']} --profile {self.store.profile} --wait --timeout 45 --json"}}
213
+ result = {"status": "completed", "job_id": job["job_id"], **job["result"]}
214
+ else:
215
+ result = {"status": "completed", "data": self.request(
216
+ operation["method"], operation["path"], json=operation["payload"] or None,
217
+ headers={"Idempotency-Key": operation["key"]})}
218
+ operation["status"] = "completed"
219
+ operation["result"] = {"operation_id": operation["operation_id"], **result}
220
+ self.store.save_operation(operation)
221
+ return operation["result"]
222
+
223
+ def run(self, operation, wait=False, timeout=45):
224
+ deadline = time.monotonic() + timeout
225
+ while True:
226
+ try:
227
+ result = self.advance(operation)
228
+ except APIError as exc:
229
+ if exc.status == 401 or exc.code in {"invalid_grant", "insufficient_scope"}:
230
+ self.store.secret("tokens", None)
231
+ result = self.authorization(operation)
232
+ else:
233
+ raise
234
+ if not wait or result["status"] == "completed" or time.monotonic() >= deadline:
235
+ return result
236
+ time.sleep(min(2, max(0, deadline - time.monotonic())))
@@ -0,0 +1,57 @@
1
+ """A bounded JSON MCP config adapter; existing entries are never overwritten."""
2
+
3
+ import json
4
+ import shutil
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from .storage import LocalError, atomic_json
9
+
10
+
11
+ def configuration(store):
12
+ return {"command": sys.executable, "args": ["-m", "pollaris_cli.main", "mcp", "serve",
13
+ "--profile", store.profile]}
14
+
15
+
16
+ def install(store, operation):
17
+ entry = configuration(store)
18
+ config_path = operation.get("config_file")
19
+ if not config_path:
20
+ return {"mcp_config": {"mcpServers": {"pollaris": entry}},
21
+ "installed": False, "message": "Pass --config-file to install in a JSON MCP host configuration"}
22
+ path = Path(config_path).expanduser().absolute()
23
+ if path.is_symlink():
24
+ raise LocalError("Refusing a symbolic-link config target")
25
+ existing = json.loads(path.read_text()) if path.exists() else {}
26
+ if not isinstance(existing, dict) or not isinstance(existing.get("mcpServers", {}), dict):
27
+ raise LocalError("Expected a JSON object with an mcpServers mapping")
28
+ servers = existing.setdefault("mcpServers", {})
29
+ if "pollaris" in servers and servers["pollaris"] != entry:
30
+ raise LocalError("A different Pollaris configuration exists; choose another config file")
31
+ backup = None
32
+ if path.exists() and servers.get("pollaris") != entry:
33
+ import uuid
34
+ backup = str(path) + f".pollaris-{uuid.uuid4().hex[:8]}.bak"
35
+ shutil.copy2(path, backup)
36
+ servers["pollaris"] = entry
37
+ atomic_json(path, existing)
38
+ store.write("mcp_install", {"path": str(path), "entry": entry})
39
+ return {"installed": True, "config_file": str(path), "backup": backup,
40
+ "reload_required": True, "mcp_config": {"mcpServers": {"pollaris": entry}}}
41
+
42
+
43
+ def uninstall(store):
44
+ saved = store.read("mcp_install")
45
+ if not saved:
46
+ return {"removed": False}
47
+ path = Path(saved["path"])
48
+ data = json.loads(path.read_text())
49
+ if data.get("mcpServers", {}).get("pollaris") != saved["entry"]:
50
+ raise LocalError("The managed entry changed; it was not removed")
51
+ backup = str(path) + ".pollaris-disconnect.bak"
52
+ if Path(backup).exists():
53
+ raise LocalError("Disconnect backup already exists; preserve it before retrying")
54
+ shutil.copy2(path, backup)
55
+ del data["mcpServers"]["pollaris"]
56
+ atomic_json(path, data)
57
+ return {"removed": True, "backup": backup}
@@ -0,0 +1,220 @@
1
+ """Machine-readable commands and explicit, resumable browser authorization."""
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import sys
7
+ import time
8
+ from pathlib import Path
9
+
10
+ import httpx
11
+
12
+ from . import __version__
13
+ from .client import APIError, CLIENT_ID, Client
14
+ from .storage import LocalError, Store
15
+
16
+
17
+ def parser():
18
+ p = argparse.ArgumentParser(prog="pollaris", description="Create and publish forms through your agent")
19
+ p.add_argument("--version", action="version", version=__version__)
20
+ common = argparse.ArgumentParser(add_help=False)
21
+ common.add_argument("--json", action="store_true")
22
+ common.add_argument("--profile", default="default")
23
+ common.add_argument("--api-url", default=os.getenv("POLLARIS_API_URL"))
24
+ common.add_argument("--non-interactive", action="store_true", help="Never prompt or open a browser (also the default)")
25
+ common.add_argument("--wait", action="store_true")
26
+ common.add_argument("--timeout", type=float, default=45)
27
+ groups = p.add_subparsers(dest="group", required=True)
28
+ for group, commands in {
29
+ "auth": ["login", "status", "logout"],
30
+ "forms": ["create", "get", "list", "validate", "save", "publish"],
31
+ "operations": ["resume"], "jobs": ["get", "wait", "resume"],
32
+ "workspaces": ["list", "use"], "connections": ["list", "revoke"],
33
+ "mcp": ["connect", "serve", "status", "disconnect"],
34
+ }.items():
35
+ subs = groups.add_parser(group).add_subparsers(dest="command", required=True)
36
+ for command in commands:
37
+ sub = subs.add_parser(command, parents=[common])
38
+ if (group == "forms" and command in {"get", "save", "publish"}) or group in {"jobs", "operations"} or (group == "workspaces" and command == "use") or (group == "connections" and command == "revoke"):
39
+ sub.add_argument("id")
40
+ if group == "forms" and command in {"create", "save", "publish"}:
41
+ sub.add_argument("--idempotency-key")
42
+ if group == "forms" and command in {"create", "save", "validate"}:
43
+ sub.add_argument("--definition", help="JSON definition file, or - for stdin")
44
+ if group == "forms" and command == "create":
45
+ sub.add_argument("--prompt")
46
+ sub.add_argument("--prompt-file")
47
+ sub.add_argument("--title")
48
+ modes = sub.add_mutually_exclusive_group()
49
+ modes.add_argument("--publish", action="store_true", default=True)
50
+ modes.add_argument("--draft-only", action="store_true")
51
+ sub.add_argument("--workspace")
52
+ if group == "forms" and command in {"save", "publish"}:
53
+ sub.add_argument("--expected-revision", required=True, type=int)
54
+ if group == "forms" and command == "list":
55
+ sub.add_argument("--after")
56
+ sub.add_argument("--limit", type=int, default=50)
57
+ if group == "mcp" and command == "connect":
58
+ sub.add_argument("--connection", required=True)
59
+ sub.add_argument("--config-file", help="Install into a JSON mcpServers config (for example Cursor)")
60
+ sub.add_argument("--workspace")
61
+ for command in ("capabilities", "doctor"):
62
+ groups.add_parser(command, parents=[common]).set_defaults(command=command)
63
+ return p
64
+
65
+
66
+ def read_input(path):
67
+ return sys.stdin.read() if path == "-" else Path(path).read_text()
68
+
69
+
70
+ def execute(args, store, client):
71
+ group, command = args.group, args.command
72
+ if group == "capabilities":
73
+ return {"status": "completed", "version": __version__, **client.discover()}
74
+ if group == "doctor":
75
+ return {"status": "completed", "api_url": client.api_url,
76
+ "server": client.discover(), "credential_store_available": store.secret("tokens") is not None}
77
+ if group == "operations":
78
+ return client.run(store.operation(args.id), args.wait, args.timeout)
79
+ if group == "auth" or (group == "mcp" and command == "status"):
80
+ if command == "status":
81
+ token = client.access_token()
82
+ return {"status": "completed" if token else "requires_authorization",
83
+ "authenticated": token is not None, "profile": store.read("config", {})}
84
+ if command == "logout":
85
+ tokens = store.secret("tokens")
86
+ if tokens:
87
+ config = store.read("config")
88
+ client.raw("POST", f"{config['issuer']}/oauth/revoke",
89
+ data={"client_id": CLIENT_ID, "token": tokens["refresh_token"]})
90
+ store.secret("tokens", None)
91
+ return {"status": "completed", "revoked": bool(tokens)}
92
+ if group == "mcp":
93
+ if command == "serve":
94
+ raise LocalError("MCP bridge must run outside the command lock")
95
+ if command == "disconnect":
96
+ from .connections import uninstall
97
+ tokens = store.secret("tokens")
98
+ if tokens:
99
+ config = store.read("config")
100
+ client.raw("POST", f"{config['issuer']}/oauth/revoke",
101
+ data={"client_id": CLIENT_ID, "token": tokens["refresh_token"]})
102
+ store.secret("tokens", None)
103
+ return {"status": "completed", **uninstall(store)}
104
+ op = client.operation("connect", {"workspace_id": args.workspace}, resource="mcp",
105
+ extras={"config_file": str(Path(args.config_file).expanduser().absolute()) if args.config_file else None})
106
+ elif group == "auth":
107
+ op = client.operation("login", {})
108
+ elif group == "forms" and command == "create":
109
+ prompt = read_input(args.prompt_file) if args.prompt_file else args.prompt
110
+ if sum(bool(x) for x in (args.prompt, args.prompt_file, args.definition)) != 1:
111
+ raise LocalError("Provide exactly one of --prompt, --prompt-file or --definition")
112
+ if prompt and not 10 <= len(prompt.strip()) <= 2000:
113
+ raise LocalError("Prompt must contain 10–2000 characters")
114
+ definition = json.loads(read_input(args.definition)) if args.definition else None
115
+ if definition is not None and not args.title:
116
+ raise LocalError("--title is required with --definition")
117
+ op = client.operation("create", {"prompt": prompt, "definition": definition,
118
+ "title": args.title, "workspace_id": args.workspace, "publish": not args.draft_only},
119
+ key=args.idempotency_key)
120
+ else:
121
+ method, payload = "GET", {}
122
+ path = f"/v1/agent/{group}"
123
+ if group == "forms":
124
+ if command == "list":
125
+ from urllib.parse import urlencode
126
+ path += "?" + urlencode({"limit": args.limit, **({"after": args.after} if args.after else {})})
127
+ elif command == "validate":
128
+ if not args.definition:
129
+ raise LocalError("--definition is required")
130
+ method, path, payload = "POST", path + "/validate", json.loads(read_input(args.definition))
131
+ else:
132
+ path += f"/{args.id}"
133
+ if command in {"save", "publish"}:
134
+ if args.expected_revision < 0:
135
+ raise LocalError("Revision cannot be negative")
136
+ method = "PUT" if command == "save" else "POST"
137
+ path += "/draft" if command == "save" else "/publish"
138
+ payload = {"expected_revision": args.expected_revision}
139
+ if command == "save":
140
+ if not args.definition:
141
+ raise LocalError("--definition is required")
142
+ payload["definition"] = json.loads(read_input(args.definition))
143
+ elif group == "jobs":
144
+ path += f"/{args.id}"
145
+ if command == "resume":
146
+ method, path = "POST", path + "/resume"
147
+ elif group == "connections" and command == "revoke":
148
+ method, path = "DELETE", path + f"/{args.id}"
149
+ elif group == "workspaces" and command == "use":
150
+ config = store.read("config", {})
151
+ if config.get("workspace_id") != args.id:
152
+ raise LocalError("Authorize a new profile for a different workspace")
153
+ return {"status": "completed", "workspace_id": args.id}
154
+ op = client.operation("request", payload, key=getattr(args, "idempotency_key", None),
155
+ extras={"method": method, "path": path})
156
+ result = client.run(op, args.wait, args.timeout)
157
+ if group == "jobs" and command == "wait" and result["status"] == "completed":
158
+ deadline = time.monotonic() + args.timeout
159
+ job = result["data"]
160
+ while job["status"] in {"queued", "generating", "committing", "retry_wait"} and time.monotonic() < deadline:
161
+ time.sleep(min(2, max(0, deadline - time.monotonic())))
162
+ job = client.request("GET", f"/v1/agent/jobs/{args.id}")
163
+ if job["status"] == "failed":
164
+ raise APIError(job["error"]["code"], job["error"]["message"], 422)
165
+ return {"status": "completed" if job["status"] == "completed" else
166
+ "requires_authorization" if job["status"] == "requires_authorization" else "pending",
167
+ "data": job}
168
+ return result
169
+
170
+
171
+ def main(argv=None):
172
+ args = parser().parse_args(argv)
173
+ result, code = None, 0
174
+ try:
175
+ if not 0 <= args.timeout <= 300:
176
+ raise LocalError("--timeout must be between 0 and 300 seconds")
177
+ profile = args.connection if args.group == "mcp" and args.command == "connect" else args.profile
178
+ store = Store(profile)
179
+ client = Client(store, args.api_url)
180
+ if args.group == "mcp" and args.command == "serve":
181
+ from .bridge import serve
182
+ import asyncio
183
+ asyncio.run(serve(store, args.api_url))
184
+ return 0
185
+ with store.lock:
186
+ result = execute(args, store, client)
187
+ code = {"requires_authorization": 10, "pending": 11}.get(result["status"], 0)
188
+ except APIError as exc:
189
+ code = 12 if exc.code == "access_denied" else (
190
+ 13 if exc.status in {401, 403} or exc.code in {"invalid_grant", "expired_token"} else
191
+ 21 if exc.status == 409 else 22 if exc.status == 429 else 20 if exc.status == 422 else 30)
192
+ result = {"status": "error", "error": {"code": exc.code, "message": exc.message,
193
+ "retryable": code in {22, 30}}}
194
+ except (LocalError, OSError, ValueError, ImportError) as exc:
195
+ code = 31
196
+ result = {"status": "error", "error": {"code": "local_error", "message": str(exc), "retryable": False}}
197
+ except httpx.HTTPError:
198
+ code = 30
199
+ result = {"status": "error", "error": {"code": "network_error", "message": "Unable to reach Pollaris; retry the saved operation", "retryable": True}}
200
+ except KeyboardInterrupt:
201
+ code = 11
202
+ result = {"status": "pending", "message": "Interrupted; the saved operation can be resumed"}
203
+ if args.group == "mcp" and args.command == "serve":
204
+ # Even startup errors must never corrupt the MCP stdout channel.
205
+ print(json.dumps({"schema_version": "1", **result}), file=sys.stderr)
206
+ elif args.json:
207
+ print(json.dumps({"schema_version": "1", **result}))
208
+ else:
209
+ if result.get("authorization"):
210
+ print(f"Authorize Pollaris: {result['authorization']['url']}")
211
+ print(result["next_action"]["command"])
212
+ elif result.get("form", {}).get("public_url"):
213
+ print(result["form"]["public_url"])
214
+ else:
215
+ print(json.dumps(result, indent=2))
216
+ return code
217
+
218
+
219
+ if __name__ == "__main__":
220
+ sys.exit(main())
@@ -0,0 +1,131 @@
1
+ """Atomic local state and OS-keychain credentials, serialized per profile."""
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import re
7
+ import tempfile
8
+ from pathlib import Path
9
+
10
+ class LocalError(ValueError):
11
+ pass
12
+
13
+
14
+ class ProcessLock:
15
+ """Small cross-platform advisory lock with a context-manager API."""
16
+
17
+ def __init__(self, path):
18
+ self.path = path
19
+ self.stream = None
20
+
21
+ def __enter__(self):
22
+ self.stream = open(self.path, "a+")
23
+ if os.name == "nt":
24
+ import msvcrt
25
+ self.stream.seek(0)
26
+ msvcrt.locking(self.stream.fileno(), msvcrt.LK_LOCK, 1)
27
+ else:
28
+ import fcntl
29
+ fcntl.flock(self.stream, fcntl.LOCK_EX)
30
+ return self
31
+
32
+ def __exit__(self, *_):
33
+ if os.name == "nt":
34
+ import msvcrt
35
+ self.stream.seek(0)
36
+ msvcrt.locking(self.stream.fileno(), msvcrt.LK_UNLCK, 1)
37
+ else:
38
+ import fcntl
39
+ fcntl.flock(self.stream, fcntl.LOCK_UN)
40
+ self.stream.close()
41
+
42
+
43
+ def atomic_json(path: Path, data):
44
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
45
+ fd, temporary = tempfile.mkstemp(prefix=".pollaris-", dir=path.parent)
46
+ try:
47
+ with os.fdopen(fd, "w") as stream:
48
+ json.dump(data, stream, indent=2)
49
+ stream.write("\n")
50
+ stream.flush()
51
+ os.fsync(stream.fileno())
52
+ os.replace(temporary, path)
53
+ finally:
54
+ if os.path.exists(temporary):
55
+ os.unlink(temporary)
56
+
57
+
58
+ class Store:
59
+ def __init__(self, profile="default", root=None, secrets_backend=None):
60
+ if not re.fullmatch(r"[a-zA-Z0-9_-]{1,64}", profile):
61
+ raise LocalError("Profile/connection names must use letters, digits, underscores or hyphens")
62
+ base = Path(root or os.environ.get("POLLARIS_CLI_HOME") or Path.home() / ".config/pollaris")
63
+ self.root = base / profile
64
+ self.root.mkdir(parents=True, exist_ok=True, mode=0o700)
65
+ self.profile = profile
66
+ self.lock = ProcessLock(self.root / ".lock")
67
+ self.backend = secrets_backend
68
+
69
+ def read(self, name, default=None):
70
+ path = self.root / f"{name}.json"
71
+ if not path.exists():
72
+ return default
73
+ return json.loads(path.read_text())
74
+
75
+ def write(self, name, value):
76
+ atomic_json(self.root / f"{name}.json", value)
77
+
78
+ def operation(self, operation_id):
79
+ if not re.fullmatch(r"[a-f0-9-]{36}", operation_id):
80
+ raise LocalError("Invalid operation ID")
81
+ value = self.read(f"operations/{operation_id}")
82
+ if value is None:
83
+ raise LocalError("Operation not found in this profile")
84
+ return value
85
+
86
+ def save_operation(self, operation):
87
+ self.write(f"operations/{operation['operation_id']}", operation)
88
+
89
+ def secret(self, name, value=...):
90
+ """An explicit Fernet key enables encrypted file storage on headless hosts."""
91
+ key = os.environ.get("POLLARIS_CLI_SECRET_KEY")
92
+ if key and self.backend is None:
93
+ from cryptography.fernet import Fernet
94
+ try:
95
+ cipher = Fernet(key.encode())
96
+ path = self.root / (hashlib.sha256(name.encode()).hexdigest() + ".secret.json")
97
+ if value is ...:
98
+ if not path.exists():
99
+ return None
100
+ return json.loads(cipher.decrypt(json.loads(path.read_text())["ciphertext"].encode()))
101
+ if value is None:
102
+ path.unlink(missing_ok=True)
103
+ else:
104
+ atomic_json(path, {"ciphertext": cipher.encrypt(json.dumps(value).encode()).decode()})
105
+ return None
106
+ except Exception as exc:
107
+ raise LocalError("Unable to use encrypted credential store; check POLLARIS_CLI_SECRET_KEY") from exc
108
+ try:
109
+ backend = self.backend
110
+ if backend is None:
111
+ import keyring
112
+ backend = keyring
113
+ implementation = keyring.get_keyring()
114
+ # Reject third-party plaintext fallback backends.
115
+ if not type(implementation).__module__.startswith((
116
+ "keyring.backends.macOS", "keyring.backends.Windows", "keyring.backends.SecretService",
117
+ "keyring.backends.kwallet",
118
+ )):
119
+ raise LocalError("No supported OS keychain")
120
+ account = f"{self.root.resolve()}:{name}"
121
+ if value is ...:
122
+ raw = backend.get_password("pollaris-cli", account)
123
+ return json.loads(raw) if raw else None
124
+ if value is None:
125
+ if backend.get_password("pollaris-cli", account):
126
+ backend.delete_password("pollaris-cli", account)
127
+ else:
128
+ backend.set_password("pollaris-cli", account, json.dumps(value))
129
+ return None
130
+ except Exception as exc:
131
+ raise LocalError("Configure an OS keychain or supply POLLARIS_CLI_SECRET_KEY for encrypted headless storage") from exc
@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.4
2
+ Name: pollaris-cli
3
+ Version: 0.1.0
4
+ Summary: Agent-friendly Pollaris CLI and MCP bridge
5
+ Project-URL: Repository, https://github.com/devsapariya94/Pollaris
6
+ Project-URL: Documentation, https://github.com/devsapariya94/Pollaris/blob/main/cli/README.md
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: httpx>=0.27
10
+ Requires-Dist: keyring>=25
11
+ Requires-Dist: cryptography>=43
12
+ Provides-Extra: mcp
13
+ Requires-Dist: mcp<3,>=2; extra == "mcp"
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest>=8; extra == "dev"
16
+
17
+ # Pollaris CLI
18
+
19
+ Create and publish Pollaris forms from terminal-capable agents. The CLI starts a
20
+ browser authorization flow when needed, preserves the original operation, and resumes
21
+ it after the user grants access. It returns an authorization URL; it does not open
22
+ a browser automatically.
23
+
24
+ Development install from the repository root:
25
+
26
+ ```bash
27
+ uv tool install --editable ./cli
28
+ ```
29
+
30
+ For package publication and production testing, see [publishing](PUBLISHING.md).
31
+
32
+ For the optional local MCP bridge, include its dependencies:
33
+
34
+ ```bash
35
+ uv tool install --editable './cli[mcp]'
36
+ ```
37
+
38
+ Point the CLI at an agent-enabled deployment (see [server setup](../docs/mcp/agent-access.md)):
39
+
40
+ ```bash
41
+ export POLLARIS_API_URL=https://api.example.com
42
+ pollaris capabilities --json
43
+ ```
44
+
45
+ Create a form:
46
+
47
+ ```bash
48
+ pollaris forms create \
49
+ --prompt "Create a short library feedback form" \
50
+ --publish \
51
+ --wait \
52
+ --json
53
+ ```
54
+
55
+ When the command returns `requires_authorization`, open `authorization.url` and
56
+ run the returned `next_action.command`. The finished result contains
57
+ `form.public_url`.
58
+
59
+ Creation publishes by default. Use `--draft-only` to create an editable draft.
60
+ `--wait` polls for completion up to `--timeout` seconds (default: 45); a pending
61
+ job can be checked with `pollaris jobs get JOB_ID --json` or
62
+ `pollaris jobs wait JOB_ID --json`.
63
+
64
+ To update or publish an existing form, inspect it first with
65
+ `pollaris forms get FORM_ID --json`, then pass its current `draft_revision` as
66
+ `--expected-revision` to `forms save` or `forms publish`. Saving also requires
67
+ `--definition path/to/definition.json`.
68
+
69
+ Use `pollaris --help` and each command's `--help` for options. Credentials use an
70
+ OS keychain, or encrypted file storage configured by `POLLARIS_CLI_SECRET_KEY`
71
+ (a Fernet key) on headless hosts. Local state defaults to `~/.config/pollaris`;
72
+ `POLLARIS_CLI_HOME` overrides it and `--profile` selects a separate profile.
73
+
74
+ See [OAuth agent access](../docs/mcp/agent-access.md), [MCP tools](../docs/mcp/tools.md),
75
+ and the [integration index](../docs/README.md) for deployment and connection guides.
@@ -0,0 +1,15 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/pollaris_cli/__init__.py
4
+ src/pollaris_cli/bridge.py
5
+ src/pollaris_cli/client.py
6
+ src/pollaris_cli/connections.py
7
+ src/pollaris_cli/main.py
8
+ src/pollaris_cli/storage.py
9
+ src/pollaris_cli.egg-info/PKG-INFO
10
+ src/pollaris_cli.egg-info/SOURCES.txt
11
+ src/pollaris_cli.egg-info/dependency_links.txt
12
+ src/pollaris_cli.egg-info/entry_points.txt
13
+ src/pollaris_cli.egg-info/requires.txt
14
+ src/pollaris_cli.egg-info/top_level.txt
15
+ tests/test_cli.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pollaris = pollaris_cli.main:main
@@ -0,0 +1,9 @@
1
+ httpx>=0.27
2
+ keyring>=25
3
+ cryptography>=43
4
+
5
+ [dev]
6
+ pytest>=8
7
+
8
+ [mcp]
9
+ mcp<3,>=2
@@ -0,0 +1 @@
1
+ pollaris_cli
@@ -0,0 +1,157 @@
1
+ import json
2
+
3
+ import pytest
4
+ import httpx
5
+
6
+ from pollaris_cli.client import APIError, Client
7
+ from pollaris_cli.connections import install, uninstall
8
+ from pollaris_cli.main import execute, parser
9
+ from pollaris_cli.storage import LocalError, Store
10
+
11
+
12
+ class MemorySecrets:
13
+ values = {}
14
+
15
+ @classmethod
16
+ def get_password(cls, service, account):
17
+ return cls.values.get((service, account))
18
+
19
+ @classmethod
20
+ def set_password(cls, service, account, value):
21
+ cls.values[(service, account)] = value
22
+
23
+ @classmethod
24
+ def delete_password(cls, service, account):
25
+ del cls.values[(service, account)]
26
+
27
+
28
+ def test_agent_create_command_contract():
29
+ args = parser().parse_args([
30
+ "forms", "create", "--prompt", "Create a library feedback form",
31
+ "--draft-only", "--json",
32
+ ])
33
+ assert args.group == "forms"
34
+ assert args.command == "create"
35
+ assert args.draft_only is True
36
+
37
+
38
+ def test_secret_and_operation_storage(tmp_path):
39
+ store = Store("agent-one", tmp_path, MemorySecrets)
40
+ store.secret("tokens", {"refresh_token": "secret"})
41
+ assert store.secret("tokens") == {"refresh_token": "secret"}
42
+ operation = {"operation_id": "00000000-0000-0000-0000-000000000001", "status": "prepared"}
43
+ store.save_operation(operation)
44
+ assert store.operation(operation["operation_id"])["status"] == "prepared"
45
+ assert "secret" not in "".join(path.read_text() for path in tmp_path.rglob("*.json"))
46
+
47
+
48
+ def test_mcp_config_merge_and_guarded_removal(tmp_path):
49
+ store = Store("agent-two", tmp_path / "state", MemorySecrets)
50
+ config = tmp_path / "mcp.json"
51
+ config.write_text(json.dumps({"mcpServers": {"existing": {"command": "safe"}}}))
52
+ result = install(store, {"config_file": str(config)})
53
+ assert result["installed"] is True
54
+ assert json.loads(config.read_text())["mcpServers"]["existing"] == {"command": "safe"}
55
+ assert uninstall(store)["removed"] is True
56
+ assert "pollaris" not in json.loads(config.read_text())["mcpServers"]
57
+
58
+
59
+ def test_mcp_config_refuses_an_existing_different_entry(tmp_path):
60
+ store = Store("agent-three", tmp_path / "state", MemorySecrets)
61
+ config = tmp_path / "mcp.json"
62
+ config.write_text(json.dumps({"mcpServers": {"pollaris": {"command": "other"}}}))
63
+ with pytest.raises(LocalError, match="different Pollaris"):
64
+ install(store, {"config_file": str(config)})
65
+
66
+
67
+ def test_idempotency_key_cannot_target_a_different_form(tmp_path):
68
+ store = Store("default", tmp_path, MemorySecrets)
69
+ client = Client(store)
70
+ client.operation("request", {"expected_revision": 1}, key="same-key",
71
+ extras={"method": "POST", "path": "/v1/agent/forms/one/publish"})
72
+ with pytest.raises(APIError, match="different input"):
73
+ client.operation("request", {"expected_revision": 1}, key="same-key",
74
+ extras={"method": "POST", "path": "/v1/agent/forms/two/publish"})
75
+
76
+
77
+ def test_mcp_config_refuses_symlink(tmp_path):
78
+ store = Store("default", tmp_path / "state", MemorySecrets)
79
+ target = tmp_path / "target.json"
80
+ target.write_text('{}')
81
+ link = tmp_path / "link.json"
82
+ link.symlink_to(target)
83
+ with pytest.raises(LocalError, match="symbolic-link"):
84
+ install(store, {"config_file": str(link)})
85
+ assert target.read_text() == '{}'
86
+
87
+
88
+ @pytest.mark.parametrize("status", ["failed", "requires_authorization", "queued", "completed"])
89
+ def test_job_wait_terminal_status(tmp_path, status):
90
+ class JobClient:
91
+ def operation(self, *args, **kwargs):
92
+ return {}
93
+
94
+ def run(self, *args):
95
+ return {"status": "completed", "data": {
96
+ "status": status, "error": {"code": "generation_failed", "message": "Failed"}}}
97
+
98
+ args = parser().parse_args(["jobs", "wait", "job-one", "--timeout", "0", "--json"])
99
+ if status == "failed":
100
+ with pytest.raises(APIError, match="Failed"):
101
+ execute(args, Store(root=tmp_path), JobClient())
102
+ else:
103
+ result = execute(args, Store(root=tmp_path), JobClient())
104
+ assert result["status"] == ("pending" if status == "queued" else status)
105
+
106
+
107
+ def test_create_authorize_restart_publish_and_replay(tmp_path):
108
+ calls = []
109
+
110
+ def server(request):
111
+ calls.append(request)
112
+ path = request.url.path
113
+ if path == "/v1/agent/capabilities":
114
+ data = {"issuer": "http://localhost:8000", "resource": "http://localhost:8000/v1/agent",
115
+ "mcp_url": "http://localhost:8002/mcp"}
116
+ elif path == "/oauth/device_authorization":
117
+ data = {"device_code": "private-device-code", "user_code": "ABCD-EFGH",
118
+ "verification_uri_complete": "http://localhost:3000/connect/device?user_code=ABCD-EFGH",
119
+ "expires_in": 600, "interval": 5}
120
+ elif path == "/oauth/token":
121
+ data = {"access_token": "private-access", "refresh_token": "private-refresh",
122
+ "expires_in": 900, "resource": "http://localhost:8000/v1/agent",
123
+ "workspace_id": "workspace-one", "connection_id": "connection-one"}
124
+ elif path == "/v1/agent/form-jobs":
125
+ assert request.headers["Idempotency-Key"] == "library-feedback"
126
+ assert json.loads(request.content)["publish"] is True
127
+ data = {"job_id": "job-one", "status": "queued"}
128
+ elif path == "/v1/agent/jobs/job-one":
129
+ if sum(r.url.path == path for r in calls) == 1:
130
+ return httpx.Response(200, json={"job_id": "job-one", "status": "queued"})
131
+ data = {"job_id": "job-one", "status": "completed", "result": {
132
+ "form": {"id": "form-one", "public_url": "https://forms.example.com/f/library"}}}
133
+ else:
134
+ raise AssertionError(path)
135
+ return httpx.Response(200, json=data)
136
+
137
+ store = Store("default", tmp_path, MemorySecrets)
138
+ http = httpx.Client(transport=httpx.MockTransport(server))
139
+ client = Client(store, http=http)
140
+ operation = client.operation("create", {"prompt": "Create a library feedback form", "publish": True},
141
+ key="library-feedback")
142
+ first = client.run(operation)
143
+ assert first["status"] == "requires_authorization"
144
+ assert "private-" not in json.dumps(first)
145
+ auth = store.secret(f"device:{operation['operation_id']}")
146
+ auth["next_poll"] = 0
147
+ store.secret(f"device:{operation['operation_id']}", auth)
148
+ restarted = Client(Store("default", tmp_path, MemorySecrets), http=http)
149
+ pending = restarted.run(store.operation(operation["operation_id"]))
150
+ assert pending["status"] == "pending"
151
+ assert pending["job_id"] == "job-one"
152
+ result = restarted.run(store.operation(operation["operation_id"]))
153
+ assert result["status"] == "completed"
154
+ assert result["form"]["public_url"].endswith("/f/library")
155
+ assert restarted.run(store.operation(operation["operation_id"])) == result
156
+ assert sum(r.url.path == "/v1/agent/form-jobs" for r in calls) == 1
157
+ assert "private-" not in "".join(p.read_text() for p in tmp_path.rglob("*.json"))