diff --git a/templates/config.json b/templates/config.json index 0d85efda..cbb9912e 100644 --- a/templates/config.json +++ b/templates/config.json @@ -3131,6 +3131,43 @@ "defaultResource": { "vCPU": 1, "memory": 1024, "diskSize": 10 }, "tags": ["MCP Servers", "Developer Tools", "AI Agents"] }, +{ + "id": "cline", + "name": "Cline CLI", + "description": "Open source AI coding agent for IDEs and terminals. This CPU-safe HTTP verifier installs the real cline npm package, checks package metadata, imports @cline/sdk, and runs cline --version without provider credentials, browser auth, model downloads, GPU, or hosted model calls.", + "repo": "https://github.com/Phala-Network/phala-cloud/tree/main/templates/prebuilt/cline", + "author": "cline", + "icon": "cline.png", + "envs": [ + { + "key": "CLINE_PACKAGE_VERSION", + "required": false, + "default": "3.0.13", + "description": "Published npm cline package version installed by the verifier at container startup." + }, + { + "key": "ANTHROPIC_API_KEY", + "required": false, + "description": "Optional Anthropic provider API key for later real Cline sessions. The default verifier endpoints do not use it." + }, + { + "key": "OPENAI_API_KEY", + "required": false, + "description": "Optional OpenAI-compatible provider API key for later real Cline sessions. The default verifier endpoints do not use it." + }, + { + "key": "OPENAI_BASE_URL", + "required": false, + "description": "Optional OpenAI-compatible API base URL for later custom provider workflows. The default verifier endpoints do not use it." + } + ], + "defaultResource": { + "vCPU": 1, + "memory": 2048, + "diskSize": 20 + }, + "tags": ["AI Agents", "Developer Tools", "Automation"] + }, { "id": "codex", "name": "Codex CLI", diff --git a/templates/icons/cline.png b/templates/icons/cline.png new file mode 100644 index 00000000..db6f1d8f Binary files /dev/null and b/templates/icons/cline.png differ diff --git a/templates/prebuilt/cline/README.md b/templates/prebuilt/cline/README.md new file mode 100644 index 00000000..d7858057 --- /dev/null +++ b/templates/prebuilt/cline/README.md @@ -0,0 +1,133 @@ +# Cline CLI on Phala Cloud + +## Overview + +Cline is an open source AI coding agent for IDEs and terminals. This Phala Cloud template deploys a CPU-safe HTTP verifier for the real npm `cline` package from `https://github.com/cline/cline`. + +The default container installs `cline@3.0.13`, reads package metadata, imports the installed `@cline/sdk` entrypoint, and runs `cline --version`. It does not start an agent session, perform browser authentication, call Anthropic, OpenAI, OpenRouter, Gemini, or other model providers, download models, require a GPU, or require provider credentials. + +This makes the deployment suitable as a deterministic package readiness demo for small CPU-only Phala CVMs. Real Cline agent usage still requires user-provided model/provider credentials and workspace configuration after deployment. + +## Metadata + +- Template id: `cline` +- Display name: `Cline CLI` +- Category: AI Agents & Developer Tools +- Upstream repository: https://github.com/cline/cline +- Upstream author: `cline` +- npm package: `cline@3.0.13` +- Package repository metadata: `git+https://github.com/cline/cline.git`, directory `sdk/apps/cli` +- Runtime: `node:22-bookworm-slim` +- Public HTTP port: `3000` +- Icon source: upstream `assets/icons/icon.png` from https://github.com/cline/cline, saved as `templates/icons/cline.png` + +## What This Template Runs + +- `app`: A Node.js HTTP service on port `3000`. +- Startup command: installs the pinned npm `cline` package globally, then starts `/opt/cline-demo/server.mjs`. +- Health check: calls `GET /healthz` on the local service. + +The service uses Docker Compose configs for the inline verifier script. It does not use host bind mounts, `env_file`, privileged mode, host networking, host IPC, Docker socket mounts, external build contexts, GPUs, or real secrets. + +## Deploy On Phala Cloud + +1. Create a new Phala Cloud deployment from the `cline` prebuilt template. +2. Keep the default CPU-safe resources unless you are extending the template for real interactive agent work. +3. Leave provider API keys empty for the default verifier deployment. +4. Optionally pin `CLINE_PACKAGE_VERSION` to another published npm version that still maps to `github.com/cline/cline`. +5. Open `https:///healthz` after startup completes. + +The first boot can take a few minutes because npm installs the pinned package and its dependencies. After startup, the default endpoints are local package, import, and CLI checks only. + +## Usage Endpoints + +The HTTP API is available on port `3000`. + +```bash +curl -fsS https:///healthz +curl -fsS https:///demo +curl -fsS https:///v1/models +``` + +Endpoints: + +- `GET /healthz`: Returns HTTP 200 when package metadata, the `@cline/sdk` import, and `cline --version` are ready. It reports that provider credentials are not required for health and that no provider API calls were performed. +- `GET /demo`: Runs a fresh deterministic verifier and returns safe demo mode details plus upstream npm package facts such as package name, installed version, repository metadata, license, and CLI version. +- `GET /v1/models`: Returns an OpenAI-style model list with one local verifier model id, `cline-cli/local-verifier`. This is metadata only; no LLM is hosted. +- `GET /`: Returns a compact endpoint list and current readiness status. + +Expected `/healthz` fields include: + +```json +{ + "status": "ok", + "cpu_only": true, + "credentials_required_for_health": false, + "provider_network_calls_performed": false, + "readiness": { + "package_metadata_ready": true, + "import_ready": true, + "cli_ready": true, + "version_ready": true + } +} +``` + +## Environment Variables + +No environment variable is required for the default verifier. + +| Variable | Required | Default | Description | +| --- | --- | --- | --- | +| `CLINE_PACKAGE_VERSION` | No | `3.0.13` | Published npm `cline` package version installed at container startup. Override only when testing another compatible release. | +| `PORT` | No | `3000` | HTTP port used inside the container and exposed by the compose file. | +| `ANTHROPIC_API_KEY` | No | unset | Optional provider key for future real Cline workflows. The default `/healthz`, `/demo`, and `/v1/models` endpoints do not use it or pass it to `cline --version`. | +| `OPENAI_API_KEY` | No | unset | Optional OpenAI-compatible provider key for future real Cline workflows. Not used by the default verifier endpoints. | +| `OPENAI_BASE_URL` | No | unset | Optional OpenAI-compatible base URL for future custom provider workflows. Not used by the default verifier endpoints. | + +Keep real credentials in Phala Cloud deployment environment variables or another secret manager. Do not bake API keys into this compose file, README, images, or source control. + +## Verification And Smoke Tests + +Run from the monorepo worktree root: + +```bash +python sdks/templates/validate.py +git -C sdks diff --check origin/main...HEAD +docker compose -f sdks/templates/prebuilt/cline/docker-compose.yml config >/dev/null +``` + +Optional local smoke test: + +```bash +docker compose -f sdks/templates/prebuilt/cline/docker-compose.yml up -d +curl -fsS http://localhost:3000/healthz +curl -fsS http://localhost:3000/demo +curl -fsS http://localhost:3000/v1/models +docker compose -f sdks/templates/prebuilt/cline/docker-compose.yml down +``` + +Expected smoke results: + +- `GET /healthz` returns `200 OK`. +- `.readiness.package_metadata_ready`, `.readiness.import_ready`, `.readiness.cli_ready`, and `.readiness.version_ready` are `true`. +- `/demo` reports `credentials_required: false`, `provider_network_calls_performed: false`, and `model_downloaded: false`. +- `/v1/models` includes `cline-cli/local-verifier`. + +The verifier was designed around local checks that work without model-provider credentials: installed package metadata, `@cline/sdk` import readiness, and `cline --version`. + +## Production Caveats + +This template is a readiness and packaging demo, not a hosted multi-user coding agent service. To run real Cline agent sessions, add a workspace, authentication and authorization controls, a provider configuration, and user-provided LLM/provider credentials after deployment. + +Review upstream Cline documentation before enabling headless or interactive agent workflows. Cline can edit files, run shell commands, browse, and call configured model providers, so production deployments should define a clear workspace boundary, approval policy, logging policy, and secret handling model. + +The default endpoints are unauthenticated status endpoints. Put a protected reverse proxy in front of the service before exposing private workflow information or operational controls. + +## Upstream Attribution + +- Upstream project: https://github.com/cline/cline +- Upstream README describes Cline as an open source coding agent for IDEs and terminals and documents CLI installation with `npm i -g cline`. +- npm package: `cline` +- License in npm package metadata: `Apache-2.0` +- Icon: copied from upstream `assets/icons/icon.png` and saved as `templates/icons/cline.png`. diff --git a/templates/prebuilt/cline/docker-compose.yml b/templates/prebuilt/cline/docker-compose.yml new file mode 100644 index 00000000..e4efd2ff --- /dev/null +++ b/templates/prebuilt/cline/docker-compose.yml @@ -0,0 +1,339 @@ +services: + app: + image: node:22-bookworm-slim + restart: unless-stopped + init: true + ports: + - "3000:3000" + environment: + PORT: ${PORT:-3000} + CLINE_PACKAGE_VERSION: ${CLINE_PACKAGE_VERSION:-3.0.13} + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + OPENAI_BASE_URL: ${OPENAI_BASE_URL:-} + NODE_ENV: production + HOME: /tmp/cline-home + XDG_CONFIG_HOME: /tmp/cline-config + CI: "true" + NO_COLOR: "1" + NPM_CONFIG_UPDATE_NOTIFIER: "false" + configs: + - source: cline_demo_app + target: /opt/cline-demo/server.mjs + command: + - /bin/sh + - -lc + - | + set -eu + mkdir -p "$$HOME" "$$XDG_CONFIG_HOME" /tmp/cline-workspace + npm install --global --omit=dev --no-audit --no-fund "cline@$${CLINE_PACKAGE_VERSION}" + exec node /opt/cline-demo/server.mjs + healthcheck: + test: + [ + "CMD-SHELL", + "node -e \"fetch('http://127.0.0.1:' + (process.env.PORT || '3000') + '/healthz').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))\"", + ] + interval: 30s + timeout: 10s + retries: 10 + start_period: 300s + +configs: + cline_demo_app: + content: | + import { execFileSync, spawnSync } from "node:child_process"; + import fs from "node:fs"; + import http from "node:http"; + import os from "node:os"; + import path from "node:path"; + import { pathToFileURL } from "node:url"; + + const startedAt = Date.now(); + const port = Number.parseInt(process.env.PORT || "3000", 10); + const packageName = "cline"; + const sdkPackageName = "@cline/sdk"; + const requestedVersion = process.env.CLINE_PACKAGE_VERSION || "3.0.13"; + const upstreamRepo = "https://github.com/cline/cline"; + + function cleanText(value) { + return String(value || "") + .replace(/\u001b\[[0-9;]*m/g, "") + .trim() + .slice(0, 4000); + } + + function publicEnvState() { + return { + anthropic_api_key_configured: Boolean(process.env.ANTHROPIC_API_KEY), + openai_api_key_configured: Boolean(process.env.OPENAI_API_KEY), + openai_base_url_configured: Boolean(process.env.OPENAI_BASE_URL), + }; + } + + function commandEnv() { + return { + PATH: process.env.PATH || "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + HOME: process.env.HOME || "/tmp/cline-home", + XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME || "/tmp/cline-config", + CI: "true", + NO_COLOR: "1", + TERM: "dumb", + }; + } + + function runCommand(command, args, timeoutMs) { + const started = Date.now(); + const result = spawnSync(command, args, { + encoding: "utf8", + env: commandEnv(), + timeout: timeoutMs, + }); + const timedOut = Boolean(result.error && result.error.code === "ETIMEDOUT"); + return { + ok: !timedOut && result.status === 0, + command: [command].concat(args).join(" "), + exit_code: timedOut ? 124 : result.status, + stdout: cleanText(result.stdout), + stderr: cleanText(result.stderr), + timed_out: timedOut, + error: result.error ? result.error.message : null, + duration_ms: Date.now() - started, + }; + } + + function globalPackageRoot() { + return cleanText(execFileSync("npm", ["root", "-g"], { + encoding: "utf8", + timeout: 10000, + })); + } + + function readPackageMetadata() { + try { + const packageRoot = path.join(globalPackageRoot(), packageName); + const packageJsonPath = path.join(packageRoot, "package.json"); + const data = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + const repositoryUrl = data.repository && data.repository.url ? data.repository.url : ""; + return { + ok: data.name === packageName && repositoryUrl.includes("github.com/cline/cline"), + package_root: packageRoot, + package_json_path: packageJsonPath, + name: data.name, + version: data.version, + description: data.description, + license: data.license, + repository: data.repository, + bin: data.bin, + dependencies: { + cline_sdk: data.dependencies && data.dependencies[sdkPackageName] ? data.dependencies[sdkPackageName] : null, + cline_core: data.dependencies && data.dependencies["@cline/core"] ? data.dependencies["@cline/core"] : null, + cline_agents: data.dependencies && data.dependencies["@cline/agents"] ? data.dependencies["@cline/agents"] : null, + }, + }; + } catch (error) { + return { + ok: false, + error: error.name + ": " + error.message, + }; + } + } + + async function importSdk(packageRoot) { + try { + const sdkPath = path.join(packageRoot, "node_modules", "@cline", "sdk", "dist", "index.js"); + const module = await import(pathToFileURL(sdkPath).href); + return { + ok: true, + package: sdkPackageName, + entrypoint: sdkPath, + exported_symbols_sample: Object.keys(module).sort().slice(0, 12), + }; + } catch (error) { + return { + ok: false, + package: sdkPackageName, + error: error.name + ": " + error.message, + }; + } + } + + function inspectCliVersion() { + const result = runCommand("cline", ["--version"], 15000); + return { + ok: result.ok && result.stdout.length > 0, + version: result.stdout, + check: result, + }; + } + + async function inspectCline() { + const metadata = readPackageMetadata(); + const importCheck = metadata.ok + ? await importSdk(metadata.package_root) + : { ok: false, package: sdkPackageName, error: "package metadata is unavailable" }; + const cliVersion = inspectCliVersion(); + const versionMatchesPackage = metadata.ok && cliVersion.ok && cliVersion.version === metadata.version; + return { + ok: metadata.ok && importCheck.ok && cliVersion.ok, + package: metadata, + import: importCheck, + cli: { + ok: cliVersion.ok, + version_ready: cliVersion.ok, + version: cliVersion.version, + version_matches_package: versionMatchesPackage, + check: cliVersion.check, + }, + }; + } + + const startupChecksPromise = inspectCline(); + + function basePayload(checks) { + return { + ok: checks.ok, + service: "cline-cli-verifier", + upstream: upstreamRepo, + node: process.version, + platform: os.platform() + "/" + os.arch(), + uptime_seconds: Math.round((Date.now() - startedAt) / 100) / 10, + cpu_only: true, + credentials_required_for_health: false, + provider_network_calls_performed: false, + browser_auth_started: false, + model_downloaded: false, + configured_provider_env: publicEnvState(), + readiness: { + package_metadata_ready: checks.package.ok, + import_ready: checks.import.ok, + cli_ready: checks.cli.ok, + version_ready: checks.cli.version_ready, + }, + cline: checks, + }; + } + + function writeJson(response, status, payload) { + const body = Buffer.from(JSON.stringify(payload, null, 2) + "\n", "utf8"); + response.writeHead(status, { + "Content-Type": "application/json; charset=utf-8", + "Content-Length": String(body.length), + "Cache-Control": "no-store", + }); + response.end(body); + } + + async function responseFor(pathName) { + if (pathName === "/healthz") { + const checks = await startupChecksPromise; + const payload = basePayload(checks); + payload.status = checks.ok ? "ok" : "unhealthy"; + return { status: checks.ok ? 200 : 503, body: payload }; + } + + if (pathName === "/demo") { + const checks = await inspectCline(); + const payload = basePayload(checks); + payload.demo = { + name: "Cline CLI local verifier", + mode: "safe deterministic package and CLI smoke test", + what_ran: [ + "Read package metadata from the installed npm cline package.", + "Imported the installed @cline/sdk module entrypoint.", + "Executed cline --version with provider credentials omitted from the command environment.", + ], + upstream_package_facts: { + npm_package: packageName, + requested_version: requestedVersion, + installed_version: checks.package.version || null, + repository: checks.package.repository || null, + license: checks.package.license || null, + cli_version: checks.cli.version || null, + }, + credentials_required: false, + provider_network_calls_performed: false, + model_downloaded: false, + }; + return { status: checks.ok ? 200 : 503, body: payload }; + } + + if (pathName === "/v1/models") { + const checks = await startupChecksPromise; + return { + status: 200, + body: { + object: "list", + data: [ + { + id: "cline-cli/local-verifier", + object: "model", + created: 0, + owned_by: "cline-template", + description: "OpenAI-style metadata entry for the local Cline CLI verifier. No LLM is hosted and no provider API is called.", + }, + ], + package_version: checks.package.version || null, + cli_version: checks.cli.version || null, + remote_model_calls: false, + }, + }; + } + + if (pathName === "/") { + const checks = await startupChecksPromise; + return { + status: 200, + body: { + service: "cline-cli-verifier", + endpoints: ["/healthz", "/demo", "/v1/models"], + status: checks.ok ? "ok" : "unhealthy", + }, + }; + } + + return { + status: 404, + body: { + error: "not_found", + endpoints: ["/healthz", "/demo", "/v1/models"], + }, + }; + } + + const server = http.createServer(async (request, response) => { + if (request.method !== "GET") { + writeJson(response, 405, { error: "method_not_allowed" }); + return; + } + + const requestUrl = new URL(request.url, "http://127.0.0.1"); + const pathName = requestUrl.pathname.replace(/\/+$/, "") || "/"; + + try { + const result = await responseFor(pathName); + writeJson(response, result.status, result.body); + } catch (error) { + writeJson(response, 500, { + error: "internal_error", + message: error.name + ": " + error.message, + }); + } + }); + + server.listen(port, "0.0.0.0", async () => { + const checks = await startupChecksPromise; + console.log(JSON.stringify({ + event: "startup", + service: "cline-cli-verifier", + port, + ok: checks.ok, + readiness: { + package_metadata_ready: checks.package.ok, + import_ready: checks.import.ok, + cli_ready: checks.cli.ok, + version_ready: checks.cli.version_ready, + }, + provider_credentials_required_for_health: false, + })); + });