provision-stack 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,34 @@
1
+ node_modules
2
+ dist
3
+ build
4
+ coverage
5
+
6
+ .env
7
+ .env.*
8
+ !.env.example
9
+
10
+ .turbo
11
+
12
+ .DS_Store
13
+
14
+ .vscode/settings.json
15
+
16
+ *.log
17
+
18
+ # Generated Terraform deployment artifacts (written to user's cwd, not repo)
19
+ terraform/
20
+
21
+ # Local SQLite state (DATABASE_PATH default location)
22
+ data/
23
+
24
+ # Local deploy secrets (NEVER commit — deploy-time env lives here)
25
+ .env.local
26
+ deploy/.env
27
+
28
+ # Host SSH keys + terraform state (local artifacts)
29
+ deploy/hosting/provision-stack-host-key
30
+ deploy/hosting/provision-stack-host-key.pub
31
+ deploy/hosting/.terraform/
32
+ deploy/hosting/terraform.tfstate*
33
+ deploy/hosting/.terraform.lock.hcl
34
+ __pycache__/
@@ -0,0 +1,86 @@
1
+ Metadata-Version: 2.4
2
+ Name: provision-stack
3
+ Version: 0.1.0
4
+ Summary: Python client for the Provision Stack Execution API — deploy cloud infrastructure via AI agents.
5
+ Project-URL: Homepage, https://provision-stack.com
6
+ Project-URL: Documentation, https://docs.provision-stack.com
7
+ Project-URL: Repository, https://github.com/TravisLinkey/provision-stack
8
+ Author: Provision Stack
9
+ License-Expression: MIT
10
+ Keywords: ai-agents,aws,azure,cloud,gcp,infrastructure,mcp,oracle,terraform
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Classifier: Topic :: System :: Systems Administration
21
+ Requires-Python: >=3.10
22
+ Provides-Extra: test
23
+ Requires-Dist: pytest-mock>=3.12; extra == 'test'
24
+ Requires-Dist: pytest>=8.0; extra == 'test'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # provision-stack
28
+
29
+ > Python client for the Provision Stack Execution API — deploy cloud infrastructure via AI agents.
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ pip install provision-stack
35
+ ```
36
+
37
+ ## Quick Start
38
+
39
+ ```python
40
+ from provision_stack import ProvisionStackClient
41
+
42
+ client = ProvisionStackClient(api_key="your-token", sandbox=True)
43
+
44
+ # Get suggestions
45
+ suggestions = client.suggest("deploy a production web app")
46
+
47
+ # Deploy
48
+ result = client.deploy(outcome="deploy a production web app", tier="MVP")
49
+
50
+ # Check status
51
+ status = client.status(result["deploymentId"])
52
+ ```
53
+
54
+ ## Configuration
55
+
56
+ ```python
57
+ client = ProvisionStackClient(
58
+ api_key="your-token", # or PROVISION_STACK_API_KEY env var
59
+ base_url="https://...", # or PROVISION_STACK_API_URL env var
60
+ sandbox=True, # use sandbox (mock Terraform)
61
+ )
62
+ ```
63
+
64
+ ## API
65
+
66
+ ### `suggest(outcome, **kwargs)`
67
+ Get deployment suggestions with tier options and cost estimates.
68
+
69
+ ### `deploy(outcome, **kwargs)`
70
+ Deploy infrastructure. Fee held from balance, captured after verification.
71
+
72
+ ### `status(deployment_id)`
73
+ Check deployment status and verification results.
74
+
75
+ ### `destroy(deployment_id)`
76
+ Destroy all resources owned by a deployment.
77
+
78
+ ### `balance()`
79
+ Check account credit balance.
80
+
81
+ ### `topup_stripe(amount_usd)` / `topup_xrp(amount_usd)`
82
+ Add credits via Stripe or XRP.
83
+
84
+ ## License
85
+
86
+ MIT
@@ -0,0 +1,60 @@
1
+ # provision-stack
2
+
3
+ > Python client for the Provision Stack Execution API — deploy cloud infrastructure via AI agents.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install provision-stack
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```python
14
+ from provision_stack import ProvisionStackClient
15
+
16
+ client = ProvisionStackClient(api_key="your-token", sandbox=True)
17
+
18
+ # Get suggestions
19
+ suggestions = client.suggest("deploy a production web app")
20
+
21
+ # Deploy
22
+ result = client.deploy(outcome="deploy a production web app", tier="MVP")
23
+
24
+ # Check status
25
+ status = client.status(result["deploymentId"])
26
+ ```
27
+
28
+ ## Configuration
29
+
30
+ ```python
31
+ client = ProvisionStackClient(
32
+ api_key="your-token", # or PROVISION_STACK_API_KEY env var
33
+ base_url="https://...", # or PROVISION_STACK_API_URL env var
34
+ sandbox=True, # use sandbox (mock Terraform)
35
+ )
36
+ ```
37
+
38
+ ## API
39
+
40
+ ### `suggest(outcome, **kwargs)`
41
+ Get deployment suggestions with tier options and cost estimates.
42
+
43
+ ### `deploy(outcome, **kwargs)`
44
+ Deploy infrastructure. Fee held from balance, captured after verification.
45
+
46
+ ### `status(deployment_id)`
47
+ Check deployment status and verification results.
48
+
49
+ ### `destroy(deployment_id)`
50
+ Destroy all resources owned by a deployment.
51
+
52
+ ### `balance()`
53
+ Check account credit balance.
54
+
55
+ ### `topup_stripe(amount_usd)` / `topup_xrp(amount_usd)`
56
+ Add credits via Stripe or XRP.
57
+
58
+ ## License
59
+
60
+ MIT
@@ -0,0 +1,38 @@
1
+ """
2
+ Provision Stack — Python client and tool wrappers.
3
+
4
+ This package provides a thin Python client for the Provision Stack
5
+ Execution API, plus framework-specific integrations (LangChain,
6
+ CrewAI, Composio).
7
+
8
+ Branding
9
+ --------
10
+ All user-visible strings (product name, package name, URLs) are
11
+ defined in ``config.py``. To rebrand:
12
+
13
+ 1. Edit ``config.py`` — change ``PRODUCT_NAME``, ``PYPI_PACKAGE``,
14
+ ``API_BASE_URL_DEFAULT``, and the description strings.
15
+ 2. Rename this directory and update ``pyproject.toml`` name/description.
16
+ 3. Search-replace the old name in README files.
17
+
18
+ That's it. All runtime strings flow from ``config.py``.
19
+ """
20
+
21
+ from provision_stack.config import (
22
+ PRODUCT_NAME,
23
+ API_BASE_URL_DEFAULT,
24
+ SANDBOX_URL,
25
+ )
26
+ from provision_stack.client import ProvisionStackClient
27
+ from provision_stack.tools import BaseTool, ToolResult
28
+
29
+ __version__ = "0.1.0"
30
+
31
+ __all__ = [
32
+ "PRODUCT_NAME",
33
+ "API_BASE_URL_DEFAULT",
34
+ "SANDBOX_URL",
35
+ "ProvisionStackClient",
36
+ "BaseTool",
37
+ "ToolResult",
38
+ ]
@@ -0,0 +1,435 @@
1
+ """
2
+ Thin Python client for the Provision Stack Execution API.
3
+
4
+ All API methods return parsed dicts. No third-party dependencies
5
+ beyond ``requests`` (or ``httpx`` if available).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from typing import Any, Optional
12
+
13
+ from provision_stack.config import (
14
+ API_BASE_URL_DEFAULT,
15
+ SANDBOX_URL,
16
+ DEFAULT_REGION,
17
+ DEFAULT_TIER,
18
+ )
19
+
20
+
21
+ def _http_post(
22
+ url: str,
23
+ json: dict[str, Any] | None = None,
24
+ headers: dict[str, str] | None = None,
25
+ ) -> dict[str, Any]:
26
+ """POST with requests, falling back to httpx, then urllib."""
27
+ try:
28
+ import requests
29
+
30
+ resp = requests.post(url, json=json, headers=headers or {}, timeout=30)
31
+ resp.raise_for_status()
32
+ return resp.json()
33
+ except ImportError:
34
+ pass
35
+
36
+ try:
37
+ import httpx
38
+
39
+ with httpx.Client(timeout=30) as client:
40
+ resp = client.post(url, json=json, headers=headers or {})
41
+ resp.raise_for_status()
42
+ return resp.json()
43
+ except ImportError:
44
+ pass
45
+
46
+ import urllib.request
47
+ import json as _json
48
+
49
+ req = urllib.request.Request(
50
+ url,
51
+ data=_json.dumps(json or {}).encode(),
52
+ headers={"Content-Type": "application/json", **(headers or {})},
53
+ method="POST",
54
+ )
55
+ with urllib.request.urlopen(req, timeout=30) as resp:
56
+ return _json.loads(resp.read())
57
+
58
+
59
+ def _http_get(
60
+ url: str,
61
+ headers: dict[str, str] | None = None,
62
+ ) -> dict[str, Any]:
63
+ """GET with requests, falling back to httpx, then urllib."""
64
+ try:
65
+ import requests
66
+
67
+ resp = requests.get(url, headers=headers or {}, timeout=30)
68
+ resp.raise_for_status()
69
+ return resp.json()
70
+ except ImportError:
71
+ pass
72
+
73
+ try:
74
+ import httpx
75
+
76
+ with httpx.Client(timeout=30) as client:
77
+ resp = client.get(url, headers=headers or {})
78
+ resp.raise_for_status()
79
+ return resp.json()
80
+ except ImportError:
81
+ pass
82
+
83
+ import urllib.request
84
+ import json as _json
85
+
86
+ req = urllib.request.Request(
87
+ url,
88
+ headers=headers or {},
89
+ method="GET",
90
+ )
91
+ with urllib.request.urlopen(req, timeout=30) as resp:
92
+ return _json.loads(resp.read())
93
+
94
+
95
+ def _http_delete(
96
+ url: str,
97
+ headers: dict[str, str] | None = None,
98
+ ) -> dict[str, Any]:
99
+ """DELETE with requests, falling back to httpx, then urllib."""
100
+ try:
101
+ import requests
102
+
103
+ resp = requests.delete(url, headers=headers or {}, timeout=30)
104
+ resp.raise_for_status()
105
+ return resp.json()
106
+ except ImportError:
107
+ pass
108
+
109
+ try:
110
+ import httpx
111
+
112
+ with httpx.Client(timeout=30) as client:
113
+ resp = client.delete(url, headers=headers or {})
114
+ resp.raise_for_status()
115
+ return resp.json()
116
+ except ImportError:
117
+ pass
118
+
119
+ import urllib.request
120
+ import json as _json
121
+
122
+ req = urllib.request.Request(
123
+ url,
124
+ headers=headers or {},
125
+ method="DELETE",
126
+ )
127
+ with urllib.request.urlopen(req, timeout=30) as resp:
128
+ return _json.loads(resp.read())
129
+
130
+
131
+ class ProvisionStackClient:
132
+ """
133
+ Python client for the Provision Stack Execution API.
134
+
135
+ Usage::
136
+
137
+ from provision_stack import ProvisionStackClient
138
+
139
+ client = ProvisionStackClient(api_key="your-token")
140
+
141
+ # Get suggestions
142
+ suggestions = client.suggest("deploy a production web app")
143
+
144
+ # Deploy
145
+ deploy = client.deploy(
146
+ outcome="deploy a production web app",
147
+ resources=suggestions["options"][0]["resources"],
148
+ tier="MVP",
149
+ )
150
+
151
+ # Check status
152
+ status = client.status(deploy["deploymentId"])
153
+
154
+ # Check balance
155
+ balance = client.balance()
156
+
157
+ Parameters
158
+ ----------
159
+ api_key : str, optional
160
+ Bearer token for authentication. Falls back to
161
+ ``PROVISION_STACK_API_KEY`` env var, then ``PS_API_KEY``.
162
+ base_url : str, optional
163
+ API base URL. Falls back to ``PROVISION_STACK_API_URL`` env var,
164
+ then the default production URL.
165
+ sandbox : bool, optional
166
+ If True, use the sandbox URL (mock Terraform, no real deploys).
167
+ Defaults to False.
168
+ """
169
+
170
+ def __init__(
171
+ self,
172
+ api_key: str | None = None,
173
+ base_url: str | None = None,
174
+ sandbox: bool = False,
175
+ ) -> None:
176
+ self.api_key = (
177
+ api_key
178
+ or os.environ.get("PROVISION_STACK_API_KEY")
179
+ or os.environ.get("PS_API_KEY")
180
+ or ""
181
+ )
182
+ if sandbox:
183
+ self.base_url = SANDBOX_URL
184
+ else:
185
+ self.base_url = (
186
+ base_url
187
+ or os.environ.get("PROVISION_STACK_API_URL")
188
+ or API_BASE_URL_DEFAULT
189
+ )
190
+
191
+ def _headers(self) -> dict[str, str]:
192
+ headers: dict[str, str] = {}
193
+ if self.api_key:
194
+ headers["Authorization"] = f"Bearer {self.api_key}"
195
+ return headers
196
+
197
+ def _url(self, path: str) -> str:
198
+ return f"{self.base_url.rstrip('/')}{path}"
199
+
200
+ # ── Auth ──────────────────────────────────────────────────────
201
+
202
+ def signup(self, agent_id: str, scopes: list[str] | None = None) -> dict:
203
+ """
204
+ Create a new API token (self-serve signup).
205
+
206
+ Returns the token and next-step guidance.
207
+ """
208
+ return _http_post(
209
+ self._url("/auth/tokens"),
210
+ json={"agentId": agent_id, "scopes": scopes or []},
211
+ )
212
+
213
+ # ── Suggestions ───────────────────────────────────────────────
214
+
215
+ def suggest(
216
+ self,
217
+ outcome: str,
218
+ region: str | None = None,
219
+ provider: str | None = None,
220
+ cross_provider: bool = False,
221
+ max_monthly_cost_usd: float | None = None,
222
+ requirements: dict[str, Any] | None = None,
223
+ ) -> dict:
224
+ """
225
+ Get deployment suggestions for a desired outcome.
226
+
227
+ Parameters
228
+ ----------
229
+ outcome : str
230
+ Natural language description (e.g. "deploy a production web app").
231
+ region : str, optional
232
+ AWS/GCP/Azure region (e.g. "us-east-1").
233
+ provider : str, optional
234
+ Restrict to one provider ("aws", "gcp", "azure", "oracle").
235
+ cross_provider : bool
236
+ If True, compare across all providers.
237
+ max_monthly_cost_usd : float, optional
238
+ Filter out options above this monthly cost.
239
+ requirements : dict, optional
240
+ Business requirements: budget, availability, compliance,
241
+ trafficPattern, computePreference.
242
+
243
+ Returns
244
+ -------
245
+ dict
246
+ Suggestions with options, cost estimates, and optional
247
+ clarificationRequest.
248
+ """
249
+ body: dict[str, Any] = {"outcome": outcome}
250
+ if region:
251
+ body["region"] = region
252
+ if provider:
253
+ body["provider"] = provider
254
+ if cross_provider:
255
+ body["crossProvider"] = True
256
+ if max_monthly_cost_usd is not None:
257
+ body["maxMonthlyCostUsd"] = max_monthly_cost_usd
258
+ if requirements:
259
+ body["requirements"] = requirements
260
+
261
+ return _http_post(
262
+ self._url("/api/v1/suggestions"),
263
+ json=body,
264
+ headers=self._headers(),
265
+ )
266
+
267
+ # ── Quote ─────────────────────────────────────────────────────
268
+
269
+ def quote(
270
+ self,
271
+ outcome: str,
272
+ region: str | None = None,
273
+ tier: str | None = None,
274
+ max_monthly_cost_usd: float | None = None,
275
+ ) -> dict:
276
+ """
277
+ Get a pricing quote for a deployment.
278
+
279
+ Returns a quote ID and estimated monthly cost.
280
+ """
281
+ body: dict[str, Any] = {"outcome": outcome}
282
+ if region:
283
+ body["region"] = region
284
+ if tier:
285
+ body["tier"] = tier
286
+ if max_monthly_cost_usd is not None:
287
+ body["maxMonthlyCostUsd"] = max_monthly_cost_usd
288
+
289
+ return _http_post(
290
+ self._url("/api/v1/quote"),
291
+ json=body,
292
+ headers=self._headers(),
293
+ )
294
+
295
+ # ── Deploy ────────────────────────────────────────────────────
296
+
297
+ def deploy(
298
+ self,
299
+ outcome: str,
300
+ resources: list[dict[str, Any]] | None = None,
301
+ tier: str | None = None,
302
+ region: str | None = None,
303
+ max_monthly_cost_usd: float | None = None,
304
+ requirements: dict[str, Any] | None = None,
305
+ ) -> dict:
306
+ """
307
+ Deploy infrastructure for a desired outcome.
308
+
309
+ The deploy fee is held from your balance and captured only
310
+ after verification passes (failed deployments are free).
311
+
312
+ Parameters
313
+ ----------
314
+ outcome : str
315
+ Natural language description.
316
+ resources : list[dict], optional
317
+ Resource configs from a suggestion option.
318
+ tier : str, optional
319
+ Deployment tier (Starter/MVP/Startup/Enterprise).
320
+ region : str, optional
321
+ Target region.
322
+ max_monthly_cost_usd : float, optional
323
+ Monthly cost cap.
324
+ requirements : dict, optional
325
+ Business requirements for pattern selection.
326
+
327
+ Returns
328
+ -------
329
+ dict
330
+ Deployment ID, status, and provider info.
331
+ """
332
+ body: dict[str, Any] = {
333
+ "outcome": outcome,
334
+ }
335
+ if resources:
336
+ body["resources"] = resources
337
+ if tier:
338
+ body["tier"] = tier
339
+ if region:
340
+ body["region"] = region
341
+ if max_monthly_cost_usd is not None:
342
+ body["maxMonthlyCostUsd"] = max_monthly_cost_usd
343
+ if requirements:
344
+ body["requirements"] = requirements
345
+
346
+ return _http_post(
347
+ self._url("/api/v1/deployments"),
348
+ json=body,
349
+ headers=self._headers(),
350
+ )
351
+
352
+ # ── Status ────────────────────────────────────────────────────
353
+
354
+ def status(self, deployment_id: str) -> dict:
355
+ """
356
+ Check deployment status and verification results.
357
+
358
+ Returns status (pending/provisioning/active/failed),
359
+ verification details, and terraform files.
360
+ """
361
+ return _http_get(
362
+ self._url(f"/api/v1/deployments/{deployment_id}"),
363
+ headers=self._headers(),
364
+ )
365
+
366
+ # ── Destroy ───────────────────────────────────────────────────
367
+
368
+ def destroy(self, deployment_id: str) -> dict:
369
+ """
370
+ Destroy all resources owned by a deployment.
371
+ """
372
+ return _http_delete(
373
+ self._url(f"/api/v1/deployments/{deployment_id}"),
374
+ headers=self._headers(),
375
+ )
376
+
377
+ # ── List deployments ──────────────────────────────────────────
378
+
379
+ def list_deployments(self) -> dict:
380
+ """
381
+ List all tracked deployments.
382
+ """
383
+ return _http_get(
384
+ self._url("/api/v1/deployments"),
385
+ headers=self._headers(),
386
+ )
387
+
388
+ # ── Billing ───────────────────────────────────────────────────
389
+
390
+ def balance(self) -> dict:
391
+ """
392
+ Check account credit balance.
393
+
394
+ Returns total, held, and available USD.
395
+ """
396
+ return _http_get(
397
+ self._url("/api/v1/billing/balance"),
398
+ headers=self._headers(),
399
+ )
400
+
401
+ def topup_stripe(self, amount_usd: float) -> dict:
402
+ """
403
+ Create a Stripe Checkout session for adding credits.
404
+
405
+ Returns a checkout URL for the user to complete payment.
406
+ """
407
+ return _http_post(
408
+ self._url("/api/v1/billing/topup/stripe"),
409
+ json={"amountUsd": amount_usd},
410
+ headers=self._headers(),
411
+ )
412
+
413
+ def topup_xrp(self, amount_usd: float) -> dict:
414
+ """
415
+ Get XRP deposit instructions for adding credits.
416
+
417
+ Returns deposit address, destination tag, and exact amount.
418
+ """
419
+ return _http_post(
420
+ self._url("/api/v1/billing/topup/xrp"),
421
+ json={"amountUsd": amount_usd},
422
+ headers=self._headers(),
423
+ )
424
+
425
+ def topup_sandbox(self, amount_usd: float) -> dict:
426
+ """
427
+ Add free sandbox credits (sandbox mode only).
428
+
429
+ Returns the credited amount.
430
+ """
431
+ return _http_post(
432
+ self._url("/api/v1/billing/topup/sandbox"),
433
+ json={"amountUsd": amount_usd},
434
+ headers=self._headers(),
435
+ )
@@ -0,0 +1,101 @@
1
+ """
2
+ Branding and configuration — single source of truth.
3
+
4
+ This is the ONLY file you need to edit when rebranding.
5
+ All user-visible strings (product name, package name, URLs,
6
+ descriptions) are defined here.
7
+
8
+ Current brand: **Provision Stack** (finalized 2026-07-20).
9
+
10
+ If the brand ever changes again:
11
+
12
+ 1. Update PRODUCT_NAME below
13
+ 2. Update PYPI_PACKAGE to match the new PyPI package name
14
+ 3. Update API_BASE_URL_DEFAULT and SANDBOX_URL to the new domain
15
+ 4. Update the description strings (short_description, long_description)
16
+ 5. Rename the plugin directories:
17
+ plugins/python/provision-stack/ → plugins/python/<new-name>-sdk/
18
+ plugins/python/provision-stack-langchain/ → plugins/python/<new-name>-langchain/
19
+ plugins/python/provision-stack-crewai/ → plugins/python/<new-name>-crewai/
20
+ plugins/python/provision-stack-composio/ → plugins/python/<new-name>-composio/
21
+ 6. Rename Python package directories:
22
+ provision_stack/ → <new_name>/
23
+ provision_stack_langchain/ → <new_name>_langchain/
24
+ provision_stack_crewai/ → <new_name>_crewai/
25
+ provision_stack_composio/ → <new_name>_composio/
26
+ 7. Update pyproject.toml name/description in each package
27
+ 8. Update all import statements across the plugin packages
28
+
29
+ This is the ONLY file that needs to change for steps 1-4.
30
+ Steps 5-8 are mechanical find-replace.
31
+ """
32
+
33
+ # ── Product identity ──────────────────────────────────────────────
34
+ # Change these when rebranding. Everything else derives from them.
35
+
36
+ PRODUCT_NAME = "Provision Stack"
37
+ PYPI_PACKAGE = "provision-stack" # PyPI distribution name
38
+
39
+ # ── API endpoints ─────────────────────────────────────────────────
40
+ # These are the live API URLs. Sandbox uses SANDBOX_URL.
41
+
42
+ API_BASE_URL_DEFAULT = "https://api.provision-stack.com"
43
+ SANDBOX_URL = "https://sandbox.provision-stack.com"
44
+
45
+ # ── User-visible strings ──────────────────────────────────────────
46
+ # Used in tool descriptions, error messages, and docs.
47
+
48
+ SHORT_DESCRIPTION = (
49
+ "Deploy production infrastructure via AI agents — "
50
+ "AWS, GCP, Azure, and Oracle Cloud."
51
+ )
52
+
53
+ LONG_DESCRIPTION = (
54
+ "{product} lets AI agents provision and manage cloud "
55
+ "infrastructure through natural language. Describe your outcome "
56
+ "(e.g. 'deploy a production web application') and receive "
57
+ "verified, best-practice infrastructure on your own cloud account. "
58
+ "Supports AWS, Google Cloud, Azure, and Oracle Cloud."
59
+ )
60
+
61
+ TOOL_DESCRIPTION_SUGGEST = (
62
+ "Get deployment suggestions for a desired outcome. "
63
+ "Returns tier options (Starter/MVP/Startup/Enterprise) with "
64
+ "monthly cost estimates across multiple cloud providers."
65
+ )
66
+
67
+ TOOL_DESCRIPTION_DEPLOY = (
68
+ "Deploy infrastructure for a selected suggestion. "
69
+ "The deploy fee is held from your account balance and captured "
70
+ "only after verification passes (failed deployments are free)."
71
+ )
72
+
73
+ TOOL_DESCRIPTION_STATUS = (
74
+ "Check the status and verification results of a deployment."
75
+ )
76
+
77
+ TOOL_DESCRIPTION_DESTROY = (
78
+ "Destroy all resources owned by a deployment."
79
+ )
80
+
81
+ TOOL_DESCRIPTION_BALANCE = (
82
+ "Check your account credit balance."
83
+ )
84
+
85
+ TOOL_DESCRIPTION_TOPUP = (
86
+ "Add credits to your account via Stripe (card) or XRP (crypto)."
87
+ )
88
+
89
+ # ── Defaults ──────────────────────────────────────────────────────
90
+
91
+ DEFAULT_REGION = "us-east-1"
92
+ DEFAULT_TIER = "MVP"
93
+
94
+ # ── Sandbox note ──────────────────────────────────────────────────
95
+ # Shown to users when connecting to the sandbox.
96
+
97
+ SANDBOX_NOTE = (
98
+ "Sandbox mode: infrastructure is mocked (no real cloud resources "
99
+ "are created). Top up with POST /api/v1/billing/topup/sandbox "
100
+ "to get free credits."
101
+ )
@@ -0,0 +1,161 @@
1
+ """
2
+ Base tool definitions for the Provision Stack client.
3
+
4
+ These are framework-agnostic tool definitions that can be wrapped
5
+ by LangChain, CrewAI, Composio, or any other agent framework.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from typing import Any, Callable, Optional
12
+
13
+ from provision_stack.client import ProvisionStackClient
14
+ from provision_stack.config import (
15
+ PRODUCT_NAME,
16
+ TOOL_DESCRIPTION_SUGGEST,
17
+ TOOL_DESCRIPTION_DEPLOY,
18
+ TOOL_DESCRIPTION_STATUS,
19
+ TOOL_DESCRIPTION_DESTROY,
20
+ TOOL_DESCRIPTION_BALANCE,
21
+ TOOL_DESCRIPTION_TOPUP,
22
+ )
23
+
24
+
25
+ @dataclass
26
+ class ToolResult:
27
+ """Standardized result from a tool call."""
28
+
29
+ success: bool
30
+ data: dict[str, Any]
31
+ error: str | None = None
32
+
33
+ def to_dict(self) -> dict[str, Any]:
34
+ result = {"success": self.success, **self.data}
35
+ if self.error:
36
+ result["error"] = self.error
37
+ return result
38
+
39
+
40
+ @dataclass
41
+ class BaseTool:
42
+ """
43
+ Base class for framework-specific tool wrappers.
44
+
45
+ Each tool has a name, description, and a ``run`` method that
46
+ delegates to the ProvisionStackClient.
47
+
48
+ Framework plugins (LangChain, CrewAI, Composio) subclass this
49
+ and add framework-specific metadata (args schemas, return types).
50
+ """
51
+
52
+ name: str
53
+ description: str
54
+ client: ProvisionStackClient
55
+ _run: Callable[..., Any]
56
+
57
+ def run(self, **kwargs: Any) -> ToolResult:
58
+ """Execute the tool with the given arguments."""
59
+ try:
60
+ data = self._run(**kwargs)
61
+ return ToolResult(success=True, data=data)
62
+ except Exception as e:
63
+ return ToolResult(success=False, data={}, error=str(e))
64
+
65
+
66
+ def create_suggest_tool(client: ProvisionStackClient) -> BaseTool:
67
+ """Create a 'suggest' tool bound to the given client."""
68
+ return BaseTool(
69
+ name="suggest",
70
+ description=TOOL_DESCRIPTION_SUGGEST,
71
+ client=client,
72
+ _run=lambda outcome, region=None, provider=None,
73
+ cross_provider=False, max_monthly_cost_usd=None,
74
+ requirements=None, **kw: client.suggest(
75
+ outcome=outcome,
76
+ region=region,
77
+ provider=provider,
78
+ cross_provider=cross_provider,
79
+ max_monthly_cost_usd=max_monthly_cost_usd,
80
+ requirements=requirements,
81
+ ),
82
+ )
83
+
84
+
85
+ def create_deploy_tool(client: ProvisionStackClient) -> BaseTool:
86
+ """Create a 'deploy' tool bound to the given client."""
87
+ return BaseTool(
88
+ name="deploy",
89
+ description=TOOL_DESCRIPTION_DEPLOY,
90
+ client=client,
91
+ _run=lambda outcome, resources=None, tier=None,
92
+ region=None, max_monthly_cost_usd=None,
93
+ requirements=None, **kw: client.deploy(
94
+ outcome=outcome,
95
+ resources=resources,
96
+ tier=tier,
97
+ region=region,
98
+ max_monthly_cost_usd=max_monthly_cost_usd,
99
+ requirements=requirements,
100
+ ),
101
+ )
102
+
103
+
104
+ def create_status_tool(client: ProvisionStackClient) -> BaseTool:
105
+ """Create a 'status' tool bound to the given client."""
106
+ return BaseTool(
107
+ name="status",
108
+ description=TOOL_DESCRIPTION_STATUS,
109
+ client=client,
110
+ _run=lambda deployment_id, **kw: client.status(deployment_id),
111
+ )
112
+
113
+
114
+ def create_destroy_tool(client: ProvisionStackClient) -> BaseTool:
115
+ """Create a 'destroy' tool bound to the given client."""
116
+ return BaseTool(
117
+ name="destroy",
118
+ description=TOOL_DESCRIPTION_DESTROY,
119
+ client=client,
120
+ _run=lambda deployment_id, **kw: client.destroy(deployment_id),
121
+ )
122
+
123
+
124
+ def create_balance_tool(client: ProvisionStackClient) -> BaseTool:
125
+ """Create a 'balance' tool bound to the given client."""
126
+ return BaseTool(
127
+ name="balance",
128
+ description=TOOL_DESCRIPTION_BALANCE,
129
+ client=client,
130
+ _run=lambda **kw: client.balance(),
131
+ )
132
+
133
+
134
+ def create_topup_tool(client: ProvisionStackClient) -> BaseTool:
135
+ """Create a 'topup' tool bound to the given client."""
136
+ return BaseTool(
137
+ name="topup",
138
+ description=TOOL_DESCRIPTION_TOPUP,
139
+ client=client,
140
+ _run=lambda amount_usd, rail="stripe", **kw: (
141
+ client.topup_stripe(amount_usd) if rail == "stripe"
142
+ else client.topup_xrp(amount_usd)
143
+ ),
144
+ )
145
+
146
+
147
+ def get_all_tools(client: ProvisionStackClient) -> list[BaseTool]:
148
+ """
149
+ Get all available tools bound to the given client.
150
+
151
+ Returns a list of BaseTool instances that can be used by any
152
+ framework plugin.
153
+ """
154
+ return [
155
+ create_suggest_tool(client),
156
+ create_deploy_tool(client),
157
+ create_status_tool(client),
158
+ create_destroy_tool(client),
159
+ create_balance_tool(client),
160
+ create_topup_tool(client),
161
+ ]
@@ -0,0 +1,51 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "provision-stack"
7
+ version = "0.1.0"
8
+ description = "Python client for the Provision Stack Execution API — deploy cloud infrastructure via AI agents."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [
13
+ { name = "Provision Stack" },
14
+ ]
15
+ keywords = [
16
+ "ai-agents",
17
+ "infrastructure",
18
+ "aws",
19
+ "gcp",
20
+ "azure",
21
+ "oracle",
22
+ "terraform",
23
+ "mcp",
24
+ "cloud",
25
+ ]
26
+ classifiers = [
27
+ "Development Status :: 4 - Beta",
28
+ "Intended Audience :: Developers",
29
+ "License :: OSI Approved :: MIT License",
30
+ "Programming Language :: Python :: 3",
31
+ "Programming Language :: Python :: 3.10",
32
+ "Programming Language :: Python :: 3.11",
33
+ "Programming Language :: Python :: 3.12",
34
+ "Programming Language :: Python :: 3.13",
35
+ "Topic :: Software Development :: Libraries :: Python Modules",
36
+ "Topic :: System :: Systems Administration",
37
+ ]
38
+
39
+ [project.urls]
40
+ Homepage = "https://provision-stack.com"
41
+ Documentation = "https://docs.provision-stack.com"
42
+ Repository = "https://github.com/TravisLinkey/provision-stack"
43
+
44
+ [project.optional-dependencies]
45
+ test = [
46
+ "pytest>=8.0",
47
+ "pytest-mock>=3.12",
48
+ ]
49
+
50
+ [tool.pytest.ini_options]
51
+ testpaths = ["tests"]
@@ -0,0 +1 @@
1
+ """Shared test fixtures for provision-stack tests."""
@@ -0,0 +1,238 @@
1
+ """Tests for provision_stack.client — REST API client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import urllib.request
7
+ from unittest.mock import MagicMock, patch, MagicMock
8
+ import pytest
9
+
10
+ from provision_stack.client import ProvisionStackClient, _http_post, _http_get, _http_delete
11
+ from provision_stack.config import API_BASE_URL_DEFAULT, SANDBOX_URL
12
+
13
+
14
+ # ── Fixtures ──────────────────────────────────────────────────────
15
+
16
+
17
+ @pytest.fixture
18
+ def client():
19
+ return ProvisionStackClient(api_key="test-token-123")
20
+
21
+
22
+ @pytest.fixture
23
+ def sandbox_client():
24
+ return ProvisionStackClient(api_key="test-token-123", sandbox=True)
25
+
26
+
27
+ @pytest.fixture
28
+ def mock_response():
29
+ """Create a mock response object for requests/httpx."""
30
+ resp = MagicMock()
31
+ resp.json.return_value = {"success": True, "data": "test"}
32
+ resp.raise_for_status = MagicMock()
33
+ return resp
34
+
35
+
36
+ # ── Client initialization ─────────────────────────────────────────
37
+
38
+
39
+ class TestClientInit:
40
+ def test_default_url(self, client):
41
+ assert client.base_url == API_BASE_URL_DEFAULT
42
+
43
+ def test_sandbox_url(self, sandbox_client):
44
+ assert sandbox_client.base_url == SANDBOX_URL
45
+
46
+ def test_custom_url(self):
47
+ c = ProvisionStackClient(base_url="https://custom.api.com")
48
+ assert c.base_url == "https://custom.api.com"
49
+
50
+ def test_api_key_from_param(self):
51
+ c = ProvisionStackClient(api_key="my-key")
52
+ assert c.api_key == "my-key"
53
+
54
+ def test_api_key_from_env(self, monkeypatch):
55
+ monkeypatch.setenv("PROVISION_STACK_API_KEY", "env-key")
56
+ c = ProvisionStackClient()
57
+ assert c.api_key == "env-key"
58
+
59
+ def test_api_key_legacy_env(self, monkeypatch):
60
+ monkeypatch.setenv("PS_API_KEY", "legacy-key")
61
+ c = ProvisionStackClient()
62
+ assert c.api_key == "legacy-key"
63
+
64
+ def test_param_overrides_env(self, monkeypatch):
65
+ monkeypatch.setenv("PROVISION_STACK_API_KEY", "env-key")
66
+ c = ProvisionStackClient(api_key="param-key")
67
+ assert c.api_key == "param-key"
68
+
69
+ def test_sandbox_overrides_custom_url(self):
70
+ c = ProvisionStackClient(base_url="https://custom.api.com", sandbox=True)
71
+ assert c.base_url == SANDBOX_URL
72
+
73
+
74
+ # ── Headers ───────────────────────────────────────────────────────
75
+
76
+
77
+ class TestHeaders:
78
+ def test_auth_header(self, client):
79
+ headers = client._headers()
80
+ assert headers["Authorization"] == "Bearer test-token-123"
81
+
82
+ def test_no_auth_without_key(self):
83
+ c = ProvisionStackClient()
84
+ headers = c._headers()
85
+ assert "Authorization" not in headers
86
+
87
+
88
+ # ── URL construction ──────────────────────────────────────────────
89
+
90
+
91
+ class TestUrl:
92
+ def test_url_path(self, client):
93
+ assert client._url("/api/v1/test") == f"{API_BASE_URL_DEFAULT}/api/v1/test"
94
+
95
+ def test_url_strips_trailing_slash(self):
96
+ c = ProvisionStackClient(base_url="https://api.example.com/")
97
+ assert c._url("/test") == "https://api.example.com/test"
98
+
99
+
100
+ # ── Suggest ───────────────────────────────────────────────────────
101
+
102
+
103
+ class TestSuggest:
104
+ @patch("provision_stack.client._http_post")
105
+ def test_suggest_minimal(self, mock_post, client):
106
+ mock_post.return_value = {"options": []}
107
+ result = client.suggest("deploy a web app")
108
+ mock_post.assert_called_once()
109
+ call_args = mock_post.call_args
110
+ assert "suggestions" in call_args[0][0]
111
+ assert call_args[1]["json"]["outcome"] == "deploy a web app"
112
+
113
+ @patch("provision_stack.client._http_post")
114
+ def test_suggest_with_options(self, mock_post, client):
115
+ mock_post.return_value = {"options": []}
116
+ client.suggest(
117
+ "deploy a web app",
118
+ region="us-west-2",
119
+ provider="aws",
120
+ cross_provider=True,
121
+ max_monthly_cost_usd=50.0,
122
+ requirements={"budget": 100},
123
+ )
124
+ call_args = mock_post.call_args
125
+ body = call_args[1]["json"]
126
+ assert body["region"] == "us-west-2"
127
+ assert body["provider"] == "aws"
128
+ assert body["crossProvider"] is True
129
+ assert body["maxMonthlyCostUsd"] == 50.0
130
+ assert body["requirements"] == {"budget": 100}
131
+
132
+
133
+ # ── Deploy ────────────────────────────────────────────────────────
134
+
135
+
136
+ class TestDeploy:
137
+ @patch("provision_stack.client._http_post")
138
+ def test_deploy_minimal(self, mock_post, client):
139
+ mock_post.return_value = {"deploymentId": "d-123"}
140
+ result = client.deploy("deploy a web app")
141
+ body = mock_post.call_args[1]["json"]
142
+ assert body["outcome"] == "deploy a web app"
143
+
144
+ @patch("provision_stack.client._http_post")
145
+ def test_deploy_with_tier(self, mock_post, client):
146
+ mock_post.return_value = {"deploymentId": "d-123"}
147
+ client.deploy("deploy a web app", tier="MVP", region="eu-west-1")
148
+ body = mock_post.call_args[1]["json"]
149
+ assert body["tier"] == "MVP"
150
+ assert body["region"] == "eu-west-1"
151
+
152
+
153
+ # ── Status ────────────────────────────────────────────────────────
154
+
155
+
156
+ class TestStatus:
157
+ @patch("provision_stack.client._http_get")
158
+ def test_status(self, mock_get, client):
159
+ mock_get.return_value = {"status": "active"}
160
+ result = client.status("d-123")
161
+ mock_get.assert_called_once()
162
+ assert "d-123" in mock_get.call_args[0][0]
163
+ assert result["status"] == "active"
164
+
165
+
166
+ # ── Destroy ───────────────────────────────────────────────────────
167
+
168
+
169
+ class TestDestroy:
170
+ @patch("provision_stack.client._http_delete")
171
+ def test_destroy(self, mock_delete, client):
172
+ mock_delete.return_value = {"status": "destroyed"}
173
+ result = client.destroy("d-123")
174
+ mock_delete.assert_called_once()
175
+ assert "d-123" in mock_delete.call_args[0][0]
176
+
177
+
178
+ # ── Balance ───────────────────────────────────────────────────────
179
+
180
+
181
+ class TestBalance:
182
+ @patch("provision_stack.client._http_get")
183
+ def test_balance(self, mock_get, client):
184
+ mock_get.return_value = {"availableUsd": 25.0}
185
+ result = client.balance()
186
+ assert "billing/balance" in mock_get.call_args[0][0]
187
+
188
+
189
+ # ── Topup ─────────────────────────────────────────────────────────
190
+
191
+
192
+ class TestTopup:
193
+ @patch("provision_stack.client._http_post")
194
+ def test_topup_stripe(self, mock_post, client):
195
+ mock_post.return_value = {"checkoutUrl": "https://checkout.stripe.com/..."}
196
+ result = client.topup_stripe(20.0)
197
+ body = mock_post.call_args[1]["json"]
198
+ assert body["amountUsd"] == 20.0
199
+ assert "topup/stripe" in mock_post.call_args[0][0]
200
+
201
+ @patch("provision_stack.client._http_post")
202
+ def test_topup_xrp(self, mock_post, client):
203
+ mock_post.return_value = {"address": "rXXX", "tag": 123}
204
+ result = client.topup_xrp(10.0)
205
+ body = mock_post.call_args[1]["json"]
206
+ assert body["amountUsd"] == 10.0
207
+ assert "topup/xrp" in mock_post.call_args[0][0]
208
+
209
+ @patch("provision_stack.client._http_post")
210
+ def test_topup_sandbox(self, mock_post, sandbox_client):
211
+ mock_post.return_value = {"creditedUsd": 25.0}
212
+ result = sandbox_client.topup_sandbox(25.0)
213
+ body = mock_post.call_args[1]["json"]
214
+ assert body["amountUsd"] == 25.0
215
+ assert "topup/sandbox" in mock_post.call_args[0][0]
216
+
217
+
218
+ # ── Signup ────────────────────────────────────────────────────────
219
+
220
+
221
+ class TestSignup:
222
+ @patch("provision_stack.client._http_post")
223
+ def test_signup(self, mock_post, client):
224
+ mock_post.return_value = {"token": "new-token"}
225
+ result = client.signup("my-agent")
226
+ body = mock_post.call_args[1]["json"]
227
+ assert body["agentId"] == "my-agent"
228
+
229
+
230
+ # ── List deployments ──────────────────────────────────────────────
231
+
232
+
233
+ class TestListDeployments:
234
+ @patch("provision_stack.client._http_get")
235
+ def test_list_deployments(self, mock_get, client):
236
+ mock_get.return_value = {"items": []}
237
+ result = client.list_deployments()
238
+ assert "deployments" in mock_get.call_args[0][0]
@@ -0,0 +1,43 @@
1
+ """Tests for provision_stack.config — branding configuration."""
2
+
3
+ from provision_stack.config import (
4
+ PRODUCT_NAME,
5
+ PYPI_PACKAGE,
6
+ API_BASE_URL_DEFAULT,
7
+ SANDBOX_URL,
8
+ SHORT_DESCRIPTION,
9
+ LONG_DESCRIPTION,
10
+ TOOL_DESCRIPTION_SUGGEST,
11
+ DEFAULT_REGION,
12
+ DEFAULT_TIER,
13
+ )
14
+
15
+
16
+ class TestConfig:
17
+ def test_product_name_is_string(self):
18
+ assert isinstance(PRODUCT_NAME, str)
19
+ assert len(PRODUCT_NAME) > 0
20
+
21
+ def test_pypi_package_is_string(self):
22
+ assert isinstance(PYPI_PACKAGE, str)
23
+ assert len(PYPI_PACKAGE) > 0
24
+
25
+ def test_urls_are_valid(self):
26
+ assert API_BASE_URL_DEFAULT.startswith("https://")
27
+ assert SANDBOX_URL.startswith("https://")
28
+
29
+ def test_descriptions_are_strings(self):
30
+ assert isinstance(SHORT_DESCRIPTION, str)
31
+ assert isinstance(LONG_DESCRIPTION, str)
32
+ assert len(SHORT_DESCRIPTION) > 10
33
+ assert len(LONG_DESCRIPTION) > 50
34
+
35
+ def test_tool_descriptions_exist(self):
36
+ assert isinstance(TOOL_DESCRIPTION_SUGGEST, str)
37
+ assert len(TOOL_DESCRIPTION_SUGGEST) > 10
38
+
39
+ def test_defaults_are_set(self):
40
+ assert isinstance(DEFAULT_REGION, str)
41
+ assert isinstance(DEFAULT_TIER, str)
42
+ assert len(DEFAULT_REGION) > 0
43
+ assert len(DEFAULT_TIER) > 0
@@ -0,0 +1,109 @@
1
+ """Tests for provision_stack.tools — framework-agnostic tool definitions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from unittest.mock import MagicMock, patch
6
+ import pytest
7
+
8
+ from provision_stack.tools import (
9
+ BaseTool,
10
+ ToolResult,
11
+ create_suggest_tool,
12
+ create_deploy_tool,
13
+ create_status_tool,
14
+ create_destroy_tool,
15
+ create_balance_tool,
16
+ create_topup_tool,
17
+ get_all_tools,
18
+ )
19
+ from provision_stack.client import ProvisionStackClient
20
+
21
+
22
+ # ── Fixtures ──────────────────────────────────────────────────────
23
+
24
+
25
+ @pytest.fixture
26
+ def client():
27
+ return ProvisionStackClient(api_key="test-token")
28
+
29
+
30
+ # ── ToolResult ────────────────────────────────────────────────────
31
+
32
+
33
+ class TestToolResult:
34
+ def test_success(self):
35
+ r = ToolResult(success=True, data={"key": "value"})
36
+ d = r.to_dict()
37
+ assert d["success"] is True
38
+ assert d["key"] == "value"
39
+ assert "error" not in d
40
+
41
+ def test_failure(self):
42
+ r = ToolResult(success=False, data={}, error="something broke")
43
+ d = r.to_dict()
44
+ assert d["success"] is False
45
+ assert d["error"] == "something broke"
46
+
47
+
48
+ # ── BaseTool ──────────────────────────────────────────────────────
49
+
50
+
51
+ class TestBaseTool:
52
+ def test_run_success(self, client):
53
+ def my_fn(x):
54
+ return {"result": x * 2}
55
+
56
+ tool = BaseTool(name="test", description="desc", client=client, _run=my_fn)
57
+ result = tool.run(x=5)
58
+ assert result.success is True
59
+ assert result.data["result"] == 10
60
+
61
+ def test_run_exception(self, client):
62
+ def my_fn():
63
+ raise ValueError("bad input")
64
+
65
+ tool = BaseTool(name="test", description="desc", client=client, _run=my_fn)
66
+ result = tool.run()
67
+ assert result.success is False
68
+ assert "bad input" in result.error
69
+
70
+
71
+ # ── Factory functions ─────────────────────────────────────────────
72
+
73
+
74
+ class TestFactoryFunctions:
75
+ def test_suggest_tool(self, client):
76
+ tool = create_suggest_tool(client)
77
+ assert tool.name == "suggest"
78
+ assert tool.client is client
79
+
80
+ def test_deploy_tool(self, client):
81
+ tool = create_deploy_tool(client)
82
+ assert tool.name == "deploy"
83
+
84
+ def test_status_tool(self, client):
85
+ tool = create_status_tool(client)
86
+ assert tool.name == "status"
87
+
88
+ def test_destroy_tool(self, client):
89
+ tool = create_destroy_tool(client)
90
+ assert tool.name == "destroy"
91
+
92
+ def test_balance_tool(self, client):
93
+ tool = create_balance_tool(client)
94
+ assert tool.name == "balance"
95
+
96
+ def test_topup_tool(self, client):
97
+ tool = create_topup_tool(client)
98
+ assert tool.name == "topup"
99
+
100
+ def test_get_all_tools(self, client):
101
+ tools = get_all_tools(client)
102
+ assert len(tools) == 6
103
+ names = {t.name for t in tools}
104
+ assert names == {"suggest", "deploy", "status", "destroy", "balance", "topup"}
105
+
106
+ def test_all_tools_share_client(self, client):
107
+ tools = get_all_tools(client)
108
+ for tool in tools:
109
+ assert tool.client is client