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.
- pollaris_cli-0.1.0/PKG-INFO +75 -0
- pollaris_cli-0.1.0/README.md +59 -0
- pollaris_cli-0.1.0/pyproject.toml +28 -0
- pollaris_cli-0.1.0/setup.cfg +4 -0
- pollaris_cli-0.1.0/src/pollaris_cli/__init__.py +3 -0
- pollaris_cli-0.1.0/src/pollaris_cli/bridge.py +64 -0
- pollaris_cli-0.1.0/src/pollaris_cli/client.py +236 -0
- pollaris_cli-0.1.0/src/pollaris_cli/connections.py +57 -0
- pollaris_cli-0.1.0/src/pollaris_cli/main.py +220 -0
- pollaris_cli-0.1.0/src/pollaris_cli/storage.py +131 -0
- pollaris_cli-0.1.0/src/pollaris_cli.egg-info/PKG-INFO +75 -0
- pollaris_cli-0.1.0/src/pollaris_cli.egg-info/SOURCES.txt +15 -0
- pollaris_cli-0.1.0/src/pollaris_cli.egg-info/dependency_links.txt +1 -0
- pollaris_cli-0.1.0/src/pollaris_cli.egg-info/entry_points.txt +2 -0
- pollaris_cli-0.1.0/src/pollaris_cli.egg-info/requires.txt +9 -0
- pollaris_cli-0.1.0/src/pollaris_cli.egg-info/top_level.txt +1 -0
- pollaris_cli-0.1.0/tests/test_cli.py +157 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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"))
|