ChatCoolify 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,24 @@
1
+ """ChatCoolify: AI-safe integration with the official Coolify API."""
2
+
3
+ from .client import (
4
+ DEFAULT_BASE_URL,
5
+ CoolifyAPIError,
6
+ CoolifyClient,
7
+ CoolifyConfigurationError,
8
+ CoolifyError,
9
+ CoolifyPermissionError,
10
+ PublicApplicationSpec,
11
+ )
12
+
13
+ __version__ = "0.1.0"
14
+
15
+ __all__ = [
16
+ "DEFAULT_BASE_URL",
17
+ "CoolifyAPIError",
18
+ "CoolifyClient",
19
+ "CoolifyConfigurationError",
20
+ "CoolifyError",
21
+ "CoolifyPermissionError",
22
+ "PublicApplicationSpec",
23
+ "__version__",
24
+ ]
chatcoolify/cli.py ADDED
@@ -0,0 +1,284 @@
1
+ """Command-line interface for safe Coolify inspection and automation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ import click
9
+
10
+ from . import __version__
11
+ from .client import DEFAULT_BASE_URL, CoolifyClient, CoolifyError, PublicApplicationSpec
12
+
13
+
14
+ CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
15
+
16
+
17
+ def _emit(value: Any) -> None:
18
+ click.echo(json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True, default=str))
19
+
20
+
21
+ def _client(ctx: click.Context) -> CoolifyClient:
22
+ return ctx.obj["client"]
23
+
24
+
25
+ def _handle_error(error: CoolifyError) -> None:
26
+ raise click.ClickException(str(error)) from error
27
+
28
+
29
+ def _render_tree(command: click.Group, *, brief: bool) -> str:
30
+ """Render the registered CLI surface instead of maintaining docs by hand."""
31
+
32
+ options = ["--help", "--version", "--tree", "--tree-brief", "--base-url", "--allow-write"]
33
+ lines = [command.name or "chatcoolify"]
34
+ for option in options:
35
+ lines.append(f"|- {option}")
36
+ commands = sorted(command.commands.values(), key=lambda item: item.name)
37
+ for index, subcommand in enumerate(commands):
38
+ connector = "`-" if index == len(commands) - 1 else "|-"
39
+ suffix = "" if brief else f" # {subcommand.help or subcommand.short_help or ''}".rstrip()
40
+ lines.append(f"{connector} {subcommand.name}{suffix}")
41
+ return "\n".join(lines)
42
+
43
+
44
+ def _tree_callback(brief: bool):
45
+ def callback(ctx: click.Context, _param: click.Parameter, value: bool) -> None:
46
+ if not value or ctx.resilient_parsing:
47
+ return
48
+ click.echo(_render_tree(ctx.command, brief=brief))
49
+ ctx.exit()
50
+
51
+ return callback
52
+
53
+
54
+ @click.group(name="chatcoolify", context_settings=CONTEXT_SETTINGS)
55
+ @click.version_option(__version__, prog_name="chatcoolify")
56
+ @click.option("--tree", is_flag=True, is_eager=True, expose_value=False, callback=_tree_callback(False), help="Print the registered command tree.")
57
+ @click.option("--tree-brief", is_flag=True, is_eager=True, expose_value=False, callback=_tree_callback(True), help="Print the registered command tree without descriptions.")
58
+ @click.option(
59
+ "--base-url",
60
+ envvar="COOLIFY_BASE_URL",
61
+ default=DEFAULT_BASE_URL,
62
+ show_default=True,
63
+ help="Coolify control-plane URL. Keep API tokens out of command arguments.",
64
+ )
65
+ @click.option(
66
+ "--allow-write",
67
+ is_flag=True,
68
+ default=False,
69
+ help="Allow commands that create or deploy resources. Read-only is the default.",
70
+ )
71
+ @click.pass_context
72
+ def main(ctx: click.Context, base_url: str, allow_write: bool) -> None:
73
+ """Use the official Coolify REST API with a local AI safety gate."""
74
+
75
+ ctx.ensure_object(dict)
76
+ ctx.obj["client"] = CoolifyClient.from_env(allow_write=allow_write)
77
+ if base_url != DEFAULT_BASE_URL:
78
+ ctx.obj["client"] = CoolifyClient(
79
+ base_url,
80
+ token=ctx.obj["client"].token,
81
+ allow_write=allow_write,
82
+ )
83
+
84
+
85
+ @main.command()
86
+ @click.pass_context
87
+ def health(ctx: click.Context) -> None:
88
+ """Call the public health endpoint; no API token is needed."""
89
+
90
+ try:
91
+ _emit(_client(ctx).health())
92
+ except CoolifyError as error:
93
+ _handle_error(error)
94
+
95
+
96
+ @main.command()
97
+ @click.pass_context
98
+ def team(ctx: click.Context) -> None:
99
+ """Show the current team bound to the configured API token."""
100
+
101
+ try:
102
+ _emit(_client(ctx).current_team())
103
+ except CoolifyError as error:
104
+ _handle_error(error)
105
+
106
+
107
+ @main.command("overview")
108
+ @click.pass_context
109
+ def overview(ctx: click.Context) -> None:
110
+ """Show a compact read-only inventory for the configured team."""
111
+
112
+ client = _client(ctx)
113
+ try:
114
+ _emit(
115
+ {
116
+ "team": client.current_team(),
117
+ "projects": client.list_projects(),
118
+ "applications": client.list_applications(),
119
+ "servers": client.list_servers(),
120
+ }
121
+ )
122
+ except CoolifyError as error:
123
+ _handle_error(error)
124
+
125
+
126
+ @main.command("projects")
127
+ @click.pass_context
128
+ def projects(ctx: click.Context) -> None:
129
+ """List projects visible to the configured API token."""
130
+
131
+ try:
132
+ _emit(_client(ctx).list_projects())
133
+ except CoolifyError as error:
134
+ _handle_error(error)
135
+
136
+
137
+ @main.command("applications")
138
+ @click.pass_context
139
+ def applications(ctx: click.Context) -> None:
140
+ """List applications visible to the configured API token."""
141
+
142
+ try:
143
+ _emit(_client(ctx).list_applications())
144
+ except CoolifyError as error:
145
+ _handle_error(error)
146
+
147
+
148
+ @main.command("servers")
149
+ @click.pass_context
150
+ def servers(ctx: click.Context) -> None:
151
+ """List servers visible to the configured API token."""
152
+
153
+ try:
154
+ _emit(_client(ctx).list_servers())
155
+ except CoolifyError as error:
156
+ _handle_error(error)
157
+
158
+
159
+ @main.command("deployments")
160
+ @click.argument("application_uuid")
161
+ @click.option("--skip", default=0, type=click.IntRange(min=0), show_default=True)
162
+ @click.option("--take", default=20, type=click.IntRange(min=1), show_default=True)
163
+ @click.pass_context
164
+ def deployments(ctx: click.Context, application_uuid: str, skip: int, take: int) -> None:
165
+ """List deployment history for one application."""
166
+
167
+ try:
168
+ _emit(_client(ctx).list_deployments(application_uuid, skip=skip, take=take))
169
+ except CoolifyError as error:
170
+ _handle_error(error)
171
+
172
+
173
+ @main.command("project-create")
174
+ @click.argument("name")
175
+ @click.option("--description", default=None, help="Optional project description.")
176
+ @click.pass_context
177
+ def project_create(ctx: click.Context, name: str, description: str | None) -> None:
178
+ """Create a project. Requires the global --allow-write flag."""
179
+
180
+ try:
181
+ _emit(_client(ctx).create_project(name, description=description))
182
+ except CoolifyError as error:
183
+ _handle_error(error)
184
+
185
+
186
+ def _website_spec(
187
+ project_uuid: str,
188
+ server_uuid: str,
189
+ environment_name: str,
190
+ environment_uuid: str,
191
+ repository_url: str,
192
+ branch: str,
193
+ build_pack: str,
194
+ name: str | None,
195
+ domain: str | None,
196
+ publish_directory: str | None,
197
+ port: int | None,
198
+ spa: bool,
199
+ ) -> PublicApplicationSpec:
200
+ return PublicApplicationSpec(
201
+ project_uuid=project_uuid,
202
+ server_uuid=server_uuid,
203
+ environment_name=environment_name,
204
+ environment_uuid=environment_uuid,
205
+ git_repository=repository_url,
206
+ git_branch=branch,
207
+ build_pack=build_pack,
208
+ name=name,
209
+ domains=domain,
210
+ publish_directory=publish_directory,
211
+ is_static=build_pack == "static",
212
+ is_spa=spa,
213
+ port=port,
214
+ )
215
+
216
+
217
+ def _website_options(function):
218
+ options = [
219
+ click.option("--project-uuid", required=True),
220
+ click.option("--server-uuid", required=True),
221
+ click.option("--environment-name", default="production", show_default=True),
222
+ click.option("--environment-uuid", required=True),
223
+ click.option("--repository-url", required=True),
224
+ click.option("--branch", default="main", show_default=True),
225
+ click.option(
226
+ "--build-pack",
227
+ type=click.Choice(["static", "nixpacks", "railpack", "dockerfile", "dockercompose"]),
228
+ default="static",
229
+ show_default=True,
230
+ ),
231
+ click.option("--name", default=None),
232
+ click.option("--domain", default=None, help="Full HTTPS domain, for example https://site.example.com"),
233
+ click.option("--publish-directory", default="dist", show_default=True),
234
+ click.option("--port", type=click.IntRange(min=1, max=65535), default=None),
235
+ click.option("--spa/--no-spa", default=True, show_default=True),
236
+ ]
237
+ for option in reversed(options):
238
+ function = option(function)
239
+ return function
240
+
241
+
242
+ @main.command("website-plan")
243
+ @_website_options
244
+ @click.pass_context
245
+ def website_plan(ctx: click.Context, **kwargs: Any) -> None:
246
+ """Render a website payload without making a Coolify write request."""
247
+
248
+ del ctx
249
+ try:
250
+ spec = _website_spec(**kwargs)
251
+ _emit({"endpoint": "/api/v1/applications/public", "payload": spec.as_payload(instant_deploy=False)})
252
+ except CoolifyError as error:
253
+ _handle_error(error)
254
+
255
+
256
+ @main.command("website-create")
257
+ @_website_options
258
+ @click.option("--deploy", is_flag=True, default=False, help="Ask Coolify to deploy immediately after creation.")
259
+ @click.pass_context
260
+ def website_create(ctx: click.Context, deploy: bool, **kwargs: Any) -> None:
261
+ """Create a public Git website. Requires the global --allow-write flag."""
262
+
263
+ try:
264
+ spec = _website_spec(**kwargs)
265
+ _emit(_client(ctx).create_public_application(spec, instant_deploy=deploy))
266
+ except CoolifyError as error:
267
+ _handle_error(error)
268
+
269
+
270
+ @main.command("deploy")
271
+ @click.argument("application_uuid")
272
+ @click.option("--force", is_flag=True, default=False, help="Rebuild without the existing cache.")
273
+ @click.pass_context
274
+ def deploy(ctx: click.Context, application_uuid: str, force: bool) -> None:
275
+ """Trigger a deployment. Requires the global --allow-write flag."""
276
+
277
+ try:
278
+ _emit(_client(ctx).deploy(application_uuid, force=force))
279
+ except CoolifyError as error:
280
+ _handle_error(error)
281
+
282
+
283
+ if __name__ == "__main__": # pragma: no cover
284
+ main()
chatcoolify/client.py ADDED
@@ -0,0 +1,310 @@
1
+ """Typed, fail-closed client for the official Coolify REST API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import json
7
+ import os
8
+ from typing import Any, Mapping, Sequence
9
+ from urllib.error import HTTPError, URLError
10
+ from urllib.parse import quote, urlencode, urljoin, urlparse
11
+ from urllib.request import Request, urlopen
12
+
13
+ DEFAULT_BASE_URL = "https://coolify.example.com"
14
+
15
+
16
+ class CoolifyError(RuntimeError):
17
+ """Base exception for ChatCoolify."""
18
+
19
+
20
+ class CoolifyConfigurationError(CoolifyError):
21
+ """Raised when the client lacks required local configuration."""
22
+
23
+
24
+ class CoolifyPermissionError(CoolifyError):
25
+ """Raised before a write request when explicit consent is missing."""
26
+
27
+
28
+ class CoolifyAPIError(CoolifyError):
29
+ """A sanitized error response from the Coolify API."""
30
+
31
+ def __init__(self, status: int, message: str) -> None:
32
+ self.status = status
33
+ self.message = message
34
+ super().__init__(f"Coolify API returned HTTP {status}: {message}")
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class PublicApplicationSpec:
39
+ """Minimum safe payload for a public Git-based Coolify application."""
40
+
41
+ project_uuid: str
42
+ server_uuid: str
43
+ environment_name: str
44
+ environment_uuid: str
45
+ git_repository: str
46
+ git_branch: str = "main"
47
+ build_pack: str = "static"
48
+ name: str | None = None
49
+ domains: str | None = None
50
+ publish_directory: str | None = None
51
+ is_static: bool = True
52
+ is_spa: bool = True
53
+ port: int | None = None
54
+ health_check_path: str | None = None
55
+
56
+ def as_payload(self, *, instant_deploy: bool = False) -> dict[str, Any]:
57
+ """Return the official API payload without adding sensitive fields."""
58
+
59
+ _require_text("project_uuid", self.project_uuid)
60
+ _require_text("server_uuid", self.server_uuid)
61
+ _require_text("environment_name", self.environment_name)
62
+ _require_text("environment_uuid", self.environment_uuid)
63
+ _require_repository(self.git_repository)
64
+ _require_text("git_branch", self.git_branch)
65
+ _require_text("build_pack", self.build_pack)
66
+
67
+ payload: dict[str, Any] = {
68
+ "project_uuid": self.project_uuid,
69
+ "server_uuid": self.server_uuid,
70
+ "environment_name": self.environment_name,
71
+ "environment_uuid": self.environment_uuid,
72
+ "git_repository": self.git_repository,
73
+ "git_branch": self.git_branch,
74
+ "build_pack": self.build_pack,
75
+ "is_static": self.is_static,
76
+ "is_spa": self.is_spa,
77
+ "instant_deploy": instant_deploy,
78
+ }
79
+ optional_text = {
80
+ "name": self.name,
81
+ "domains": self.domains,
82
+ "publish_directory": self.publish_directory,
83
+ "health_check_path": self.health_check_path,
84
+ }
85
+ for key, value in optional_text.items():
86
+ if value is not None and value.strip():
87
+ payload[key] = value.strip()
88
+ if self.port is not None:
89
+ if not 1 <= self.port <= 65535:
90
+ raise CoolifyConfigurationError("port must be between 1 and 65535")
91
+ payload["ports_exposes"] = str(self.port)
92
+ return payload
93
+
94
+
95
+ class CoolifyClient:
96
+ """Small client around documented Coolify REST endpoints.
97
+
98
+ Instances are read-only by default. Callers must opt in to writes with
99
+ ``allow_write=True`` and use a Coolify token that itself has write or deploy
100
+ permission. This creates a local safety gate in addition to Coolify's own
101
+ team scope and API-token permissions.
102
+ """
103
+
104
+ def __init__(
105
+ self,
106
+ base_url: str = DEFAULT_BASE_URL,
107
+ *,
108
+ token: str | None = None,
109
+ allow_write: bool = False,
110
+ timeout: float = 30.0,
111
+ ) -> None:
112
+ self.base_url = _normalise_base_url(base_url)
113
+ self.token = token.strip() if token else None
114
+ self.allow_write = allow_write
115
+ self.timeout = timeout
116
+
117
+ @classmethod
118
+ def from_env(cls, *, allow_write: bool = False) -> "CoolifyClient":
119
+ """Create a client from process environment or the active ChatEnv profile."""
120
+
121
+ profile_values = _load_chatenv_values()
122
+ return cls(
123
+ os.getenv("COOLIFY_BASE_URL") or profile_values.get("COOLIFY_BASE_URL") or DEFAULT_BASE_URL,
124
+ token=os.getenv("COOLIFY_API_TOKEN") or profile_values.get("COOLIFY_API_TOKEN"),
125
+ allow_write=allow_write,
126
+ )
127
+
128
+ def health(self) -> dict[str, Any]:
129
+ """Read the public health endpoint without an API token."""
130
+
131
+ result = self._request("GET", "/api/health", auth=False)
132
+ if isinstance(result, dict):
133
+ return result
134
+ return {"ok": str(result).strip().upper() == "OK", "response": str(result).strip()}
135
+
136
+ def current_team(self) -> Mapping[str, Any]:
137
+ return _as_mapping(self._request("GET", "/api/v1/team"), "current team")
138
+
139
+ def list_teams(self) -> Sequence[Mapping[str, Any]]:
140
+ return _as_sequence(self._request("GET", "/api/v1/teams"), "teams")
141
+
142
+ def list_projects(self) -> Sequence[Mapping[str, Any]]:
143
+ return _as_sequence(self._request("GET", "/api/v1/projects"), "projects")
144
+
145
+ def list_applications(self) -> Sequence[Mapping[str, Any]]:
146
+ return _as_sequence(self._request("GET", "/api/v1/applications"), "applications")
147
+
148
+ def list_servers(self) -> Sequence[Mapping[str, Any]]:
149
+ return _as_sequence(self._request("GET", "/api/v1/servers"), "servers")
150
+
151
+ def list_deployments(self, application_uuid: str, *, skip: int = 0, take: int = 20) -> Sequence[Mapping[str, Any]]:
152
+ _require_text("application_uuid", application_uuid)
153
+ if skip < 0 or take < 1:
154
+ raise CoolifyConfigurationError("skip must be >= 0 and take must be >= 1")
155
+ query = urlencode({"skip": skip, "take": take})
156
+ path = f"/api/v1/deployments/applications/{quote(application_uuid, safe='')}?{query}"
157
+ return _as_sequence(self._request("GET", path), "deployments")
158
+
159
+ def create_project(self, name: str, *, description: str | None = None) -> Mapping[str, Any]:
160
+ """Create a project only after the caller explicitly enabled writes."""
161
+
162
+ _require_text("name", name)
163
+ payload: dict[str, Any] = {"name": name.strip()}
164
+ if description and description.strip():
165
+ payload["description"] = description.strip()
166
+ return _as_mapping(self._request("POST", "/api/v1/projects", payload=payload, write=True), "project")
167
+
168
+ def create_public_application(
169
+ self,
170
+ spec: PublicApplicationSpec,
171
+ *,
172
+ instant_deploy: bool = False,
173
+ ) -> Mapping[str, Any]:
174
+ """Create a public Git application using Coolify's documented API."""
175
+
176
+ return _as_mapping(
177
+ self._request(
178
+ "POST",
179
+ "/api/v1/applications/public",
180
+ payload=spec.as_payload(instant_deploy=instant_deploy),
181
+ write=True,
182
+ ),
183
+ "application",
184
+ )
185
+
186
+ def deploy(self, application_uuid: str, *, force: bool = False) -> Mapping[str, Any]:
187
+ """Trigger an application deployment with an explicit deploy-capable client."""
188
+
189
+ _require_text("application_uuid", application_uuid)
190
+ return _as_mapping(
191
+ self._request(
192
+ "POST",
193
+ "/api/v1/deploy",
194
+ payload={"uuid": application_uuid, "force": force},
195
+ write=True,
196
+ ),
197
+ "deployment",
198
+ )
199
+
200
+ def _request(
201
+ self,
202
+ method: str,
203
+ path: str,
204
+ *,
205
+ payload: Mapping[str, Any] | None = None,
206
+ auth: bool = True,
207
+ write: bool = False,
208
+ ) -> Any:
209
+ if write and not self.allow_write:
210
+ raise CoolifyPermissionError(
211
+ "write operation blocked locally; recreate the client or CLI with explicit write permission"
212
+ )
213
+ if auth and not self.token:
214
+ raise CoolifyConfigurationError(
215
+ "COOLIFY_API_TOKEN is required for protected Coolify API operations"
216
+ )
217
+
218
+ url = urljoin(f"{self.base_url}/", path.lstrip("/"))
219
+ headers = {"Accept": "application/json", "User-Agent": "ChatCoolify/0.1.0"}
220
+ data = None
221
+ if auth:
222
+ headers["Authorization"] = f"Bearer {self.token}"
223
+ if payload is not None:
224
+ headers["Content-Type"] = "application/json"
225
+ data = json.dumps(payload).encode("utf-8")
226
+
227
+ request = Request(url, data=data, headers=headers, method=method.upper())
228
+ try:
229
+ with urlopen(request, timeout=self.timeout) as response: # nosec B310 - URL is user-configured API origin
230
+ content_type = response.headers.get_content_type()
231
+ raw = response.read().decode("utf-8")
232
+ if not raw:
233
+ return None
234
+ if content_type == "application/json" or raw.lstrip().startswith(("{", "[")):
235
+ return json.loads(raw)
236
+ return raw
237
+ except HTTPError as error:
238
+ message = _error_message(error)
239
+ raise CoolifyAPIError(error.code, message) from error
240
+ except URLError as error:
241
+ raise CoolifyError(f"Coolify API is unreachable: {error.reason}") from error
242
+
243
+
244
+ def _load_chatenv_values() -> Mapping[str, str]:
245
+ """Read the active package-owned ChatEnv profile without copying secrets."""
246
+
247
+ try:
248
+ from chatenv import EnvStore, get_paths
249
+
250
+ from .config import CoolifyConfig
251
+
252
+ return EnvStore(get_paths().envs_dir).load_active(CoolifyConfig)
253
+ except (ImportError, OSError, ValueError):
254
+ return {}
255
+
256
+
257
+ def _normalise_base_url(value: str) -> str:
258
+ candidate = value.strip().rstrip("/")
259
+ parsed = urlparse(candidate)
260
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
261
+ raise CoolifyConfigurationError("COOLIFY_BASE_URL must be an absolute http(s) URL")
262
+ return candidate
263
+
264
+
265
+ def _require_text(name: str, value: str) -> None:
266
+ if not value or not value.strip():
267
+ raise CoolifyConfigurationError(f"{name} is required")
268
+
269
+
270
+ def _require_repository(value: str) -> None:
271
+ _require_text("git_repository", value)
272
+ parsed = urlparse(value)
273
+ if parsed.scheme != "https" or not parsed.netloc:
274
+ raise CoolifyConfigurationError("git_repository must be an HTTPS repository URL")
275
+
276
+
277
+ def _as_mapping(value: Any, label: str) -> Mapping[str, Any]:
278
+ if not isinstance(value, Mapping):
279
+ raise CoolifyError(f"Coolify returned an unexpected {label} response")
280
+ return value
281
+
282
+
283
+ def _as_sequence(value: Any, label: str) -> Sequence[Mapping[str, Any]]:
284
+ if not isinstance(value, list) or not all(isinstance(item, Mapping) for item in value):
285
+ raise CoolifyError(f"Coolify returned an unexpected {label} response")
286
+ return value
287
+
288
+
289
+ def _error_message(error: HTTPError) -> str:
290
+ """Extract only a short server message; never include request headers or token data."""
291
+
292
+ try:
293
+ raw = error.read().decode("utf-8", errors="replace")
294
+ parsed = json.loads(raw)
295
+ if isinstance(parsed, Mapping) and isinstance(parsed.get("message"), str):
296
+ return parsed["message"][:500]
297
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError):
298
+ pass
299
+ return error.reason or "request failed"
300
+
301
+
302
+ __all__ = [
303
+ "DEFAULT_BASE_URL",
304
+ "CoolifyAPIError",
305
+ "CoolifyClient",
306
+ "CoolifyConfigurationError",
307
+ "CoolifyError",
308
+ "CoolifyPermissionError",
309
+ "PublicApplicationSpec",
310
+ ]
chatcoolify/config.py ADDED
@@ -0,0 +1,48 @@
1
+ """ChatEnv schema for ChatCoolify credentials and API endpoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ from chatenv import BaseEnvConfig, EnvField, EnvStore, get_paths
8
+
9
+
10
+ class CoolifyConfig(BaseEnvConfig):
11
+ """Typed configuration for the existing Coolify control plane."""
12
+
13
+ _title = "Coolify Configuration"
14
+ _aliases = ["coolify", "chatcoolify"]
15
+ _storage_dir = "Coolify"
16
+
17
+ COOLIFY_BASE_URL = EnvField(
18
+ "COOLIFY_BASE_URL",
19
+ default="https://coolify.example.com",
20
+ desc="Public HTTPS URL of the Coolify control plane",
21
+ )
22
+ COOLIFY_API_TOKEN = EnvField(
23
+ "COOLIFY_API_TOKEN",
24
+ desc="Team-scoped Coolify API token; use the minimum required permissions and expiration",
25
+ is_sensitive=True,
26
+ )
27
+
28
+ @classmethod
29
+ def test(cls) -> None:
30
+ """Validate a configured endpoint, while keeping unconfigured schemas offline."""
31
+
32
+ from .client import DEFAULT_BASE_URL, CoolifyClient, CoolifyError
33
+
34
+ profile_values = EnvStore(get_paths().envs_dir).load_active(cls)
35
+ base_url = os.getenv("COOLIFY_BASE_URL") or profile_values.get("COOLIFY_BASE_URL") or DEFAULT_BASE_URL
36
+ if base_url == DEFAULT_BASE_URL:
37
+ print("ChatCoolify schema loaded; configure COOLIFY_BASE_URL to run a health check.")
38
+ return
39
+ try:
40
+ result = CoolifyClient(base_url).health()
41
+ except CoolifyError as error:
42
+ raise RuntimeError(f"Coolify health check failed: {error}") from error
43
+ if not result.get("ok"):
44
+ raise RuntimeError("Coolify health check did not report OK")
45
+ print(f"Coolify health check passed for {base_url}.")
46
+
47
+
48
+ __all__ = ["CoolifyConfig"]
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.4
2
+ Name: ChatCoolify
3
+ Version: 0.1.0
4
+ Summary: AI-safe Python client and CLI for the official Coolify API
5
+ Author-email: ChatArch <1073853456@qq.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://arch.gh.wzhecnu.cn/ChatCoolify/
8
+ Project-URL: Documentation, https://arch.gh.wzhecnu.cn/ChatCoolify/
9
+ Project-URL: Repository, https://github.com/ChatArch/ChatCoolify
10
+ Project-URL: Issues, https://github.com/ChatArch/ChatCoolify/issues
11
+ Keywords: ai,chatarch,coolify,deployment,mcp,paas
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: chatenv<0.3.0,>=0.2.9
23
+ Requires-Dist: click<9.0,>=8.1
24
+ Provides-Extra: dev
25
+ Requires-Dist: build<2.0,>=1.2; extra == "dev"
26
+ Requires-Dist: pytest<10.0,>=8.0; extra == "dev"
27
+ Requires-Dist: twine<7.0,>=5.0; extra == "dev"
28
+ Provides-Extra: docs
29
+ Requires-Dist: mkdocs<2.0,>=1.6; extra == "docs"
30
+ Requires-Dist: mkdocs-material<10.0,>=9.5; extra == "docs"
31
+ Requires-Dist: mkdocs-static-i18n<2.0,>=1.2; extra == "docs"
32
+ Requires-Dist: mkdocs-minify-plugin<1.0,>=0.8; extra == "docs"
33
+ Requires-Dist: mike<3.0,>=2.0; extra == "docs"
34
+ Dynamic: license-file
35
+
36
+ <div align="center">
37
+
38
+ # ChatCoolify
39
+
40
+ AI-safe Python client and MkDocs guide for the official Coolify API.
41
+
42
+ [文档](https://arch.gh.wzhecnu.cn/ChatCoolify/) · [English](README.en.md) · [PyPI](https://pypi.org/project/ChatCoolify/) · [源码](https://github.com/ChatArch/ChatCoolify)
43
+
44
+ </div>
45
+
46
+ ## 解决什么问题
47
+
48
+ ChatCoolify 不替代 Coolify,而是把官方 REST API 变成可测试的 Python 客户端和 CLI:
49
+
50
+ - 默认只读,适合 AI 清点服务器、项目、应用与部署状态;
51
+ - 写操作必须有 Coolify Token 权限并显式传递 `--allow-write`;
52
+ - 支持 ChatEnv 配置、公开 Git 网站计划与创建、部署触发;
53
+ - 文档覆盖邀请制协作、MCP、网站、API、数据库和运维边界。
54
+
55
+ ## 安装
56
+
57
+ ```bash
58
+ python -m pip install --upgrade ChatCoolify
59
+ chatcoolify --version
60
+ chatcoolify --tree
61
+ ```
62
+
63
+ 支持 Python `>=3.10`。
64
+
65
+ ## 最快开始
66
+
67
+ ```bash
68
+ chatcoolify --base-url https://<coolify-url> health
69
+ ```
70
+
71
+ 需要受保护 API 时,先在 Coolify 创建 Team-scoped 最小权限 Token,再通过 ChatEnv 配置:
72
+
73
+ ```bash
74
+ chatenv init -t coolify -I
75
+ chatenv set COOLIFY_BASE_URL=https://<coolify-url>
76
+ chatenv set COOLIFY_API_TOKEN='<team-scoped-token>'
77
+ chatcoolify overview
78
+ ```
79
+
80
+ ## 网站计划示例
81
+
82
+ ```bash
83
+ chatcoolify website-plan \
84
+ --project-uuid PROJECT_UUID \
85
+ --server-uuid SERVER_UUID \
86
+ --environment-uuid ENVIRONMENT_UUID \
87
+ --repository-url https://github.com/example/simple-site \
88
+ --domain https://site.example.com
89
+ ```
90
+
91
+ 该命令只输出官方 API payload。真正创建资源需要同时具备 `write` Token 和显式写门:
92
+
93
+ ```bash
94
+ chatcoolify --allow-write website-create ...
95
+ ```
96
+
97
+ ## 安全边界
98
+
99
+ - 不把 Token 写入参数、仓库、日志或提示词。
100
+ - 不向普通 AI 或 CI 提供 `root` Token。
101
+ - Team 角色不是虚拟机隔离;不可信代码不应共享生产 Docker 守护进程。
102
+ - 完整操作流程见 [文档](https://arch.gh.wzhecnu.cn/ChatCoolify/)。
103
+
104
+ ## 开发
105
+
106
+ ```bash
107
+ python -m pip install -e '.[dev,docs]'
108
+ python -m pytest -q
109
+ python -m mkdocs build --strict
110
+ python -m build
111
+ python -m twine check dist/*
112
+ ```
113
+
114
+ ## 许可证
115
+
116
+ MIT。
@@ -0,0 +1,10 @@
1
+ chatcoolify/__init__.py,sha256=bUbYnP9qZr5jmZUpirt-gpsavsHki04X-0mFqpTjxFc,498
2
+ chatcoolify/cli.py,sha256=CYGLbtTPnqo49TK1V31xOJnsvj-jJyx0eQacEEZQmXM,9235
3
+ chatcoolify/client.py,sha256=Cl14yehC67DT5CwHvFizgkVEiKkhm8X5jkZOqn4uht0,11557
4
+ chatcoolify/config.py,sha256=Qq-sgi9myNf0FawaOFSprq1o5fLKTEr2vxk0v73l8Lc,1708
5
+ chatcoolify-0.1.0.dist-info/licenses/LICENSE,sha256=G-fJLQ4pkXXISE3qeN8J5Cw5VRoNongA0hbsHA9TRe8,1065
6
+ chatcoolify-0.1.0.dist-info/METADATA,sha256=Mm_nMaG1C-qFYFqUUAmQ7gI6HAQHdYL1Sss7Xt8XSUE,3625
7
+ chatcoolify-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
8
+ chatcoolify-0.1.0.dist-info/entry_points.txt,sha256=9-SlV-yBBEOx9wym6tXyTPDd9ruLXX9ErcJk8eUaNI0,105
9
+ chatcoolify-0.1.0.dist-info/top_level.txt,sha256=FnAqcxT8tBv7rlyLWLwSziJ6oTaQ97p06hq4P5qv8rM,12
10
+ chatcoolify-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,5 @@
1
+ [chatenv.configs]
2
+ chatcoolify = chatcoolify.config
3
+
4
+ [console_scripts]
5
+ chatcoolify = chatcoolify.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ChatArch
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ chatcoolify