From 120721bbc61af43e9c2fd7743f88ac5c8977186f Mon Sep 17 00:00:00 2001 From: Stefano Fiorini Date: Wed, 11 Mar 2026 22:35:50 -0500 Subject: [PATCH] feat: add nordvpn client skill --- README.md | 1 + docs/README.md | 1 + docs/nordvpn-client.md | 77 +++ skills/nordvpn-client/SKILL.md | 78 +++ .../nordvpn-client/scripts/nordvpn-client.js | 502 ++++++++++++++++++ 5 files changed, 659 insertions(+) create mode 100644 docs/nordvpn-client.md create mode 100644 skills/nordvpn-client/SKILL.md create mode 100644 skills/nordvpn-client/scripts/nordvpn-client.js diff --git a/README.md b/README.md index e0217dd..6497e9c 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ This repository contains practical OpenClaw skills and companion integrations. I |---|---|---| | `elevenlabs-stt` | Transcribe local audio files with ElevenLabs Speech-to-Text, with diarization, language hints, event tags, and JSON output. | `skills/elevenlabs-stt` | | `gitea-api` | Interact with Gitea via REST API (repos, issues, PRs, releases, branches, user info). | `skills/gitea-api` | +| `nordvpn-client` | Install, log in to, connect, disconnect, and verify NordVPN sessions on macOS and Linux. | `skills/nordvpn-client` | | `portainer` | Manage Portainer stacks via API (list, start/stop/restart, update, prune images). | `skills/portainer` | | `searxng` | Search through a local or self-hosted SearXNG instance for web, news, images, and more. | `skills/searxng` | | `web-automation` | One-shot extraction plus broader browsing/scraping with Playwright-compatible CloakBrowser (auth flows, extraction, bot-protected sites). | `skills/web-automation` | diff --git a/docs/README.md b/docs/README.md index 274c684..71027a3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,6 +6,7 @@ This folder contains detailed docs for each skill in this repository. - [`elevenlabs-stt`](elevenlabs-stt.md) — Local audio transcription through ElevenLabs Speech-to-Text - [`gitea-api`](gitea-api.md) — REST-based Gitea automation (no `tea` CLI required) +- [`nordvpn-client`](nordvpn-client.md) — Cross-platform NordVPN install, login, connect, disconnect, and verification - [`portainer`](portainer.md) — Portainer stack management (list, lifecycle, updates, image pruning) - [`searxng`](searxng.md) — Privacy-respecting metasearch via a local or self-hosted SearXNG instance - [`web-automation`](web-automation.md) — One-shot extraction plus Playwright-compatible CloakBrowser browser automation and scraping diff --git a/docs/nordvpn-client.md b/docs/nordvpn-client.md new file mode 100644 index 0000000..77163ea --- /dev/null +++ b/docs/nordvpn-client.md @@ -0,0 +1,77 @@ +# nordvpn-client + +Cross-platform NordVPN lifecycle skill for macOS and Linux. + +## What it does + +- Probes whether NordVPN is already installed +- Bootstraps NordVPN if missing +- Handles login bootstrap +- Connects to a country or city target +- Disconnects and reports status +- Verifies public IP and geolocation after connect + +## Commands + +```bash +node skills/nordvpn-client/scripts/nordvpn-client.js status +node skills/nordvpn-client/scripts/nordvpn-client.js install +node skills/nordvpn-client/scripts/nordvpn-client.js login +node skills/nordvpn-client/scripts/nordvpn-client.js verify +node skills/nordvpn-client/scripts/nordvpn-client.js verify --country "Italy" +node skills/nordvpn-client/scripts/nordvpn-client.js verify --country "Italy" --city "Milan" +node skills/nordvpn-client/scripts/nordvpn-client.js connect --country "Italy" +node skills/nordvpn-client/scripts/nordvpn-client.js connect --city "Milan" +node skills/nordvpn-client/scripts/nordvpn-client.js disconnect +``` + +## Platform behavior + +### macOS + +- install path: `brew install --cask nordvpn` +- probe order: + - `nordvpn` CLI if present + - `NordVPN.app` +- if the installed package exposes a usable CLI, the skill uses it +- otherwise it opens the app and returns a clear manual-action-required result for login/connect/disconnect + +### Linux + +- install path follows NordVPN's official installer script +- primary control path is the official `nordvpn` CLI +- token login is supported through `nordvpn login --token ` + +## Credentials + +Supported env vars: + +- `NORDVPN_TOKEN` +- `NORDVPN_TOKEN_FILE` +- `NORDVPN_USERNAME` +- `NORDVPN_PASSWORD` +- `NORDVPN_PASSWORD_FILE` + +Do not put secrets in the skill docs or repo. + +## Verification model + +`status`, `verify`, and `connect` emit JSON suitable for agent use: + +- platform +- install state +- auth state +- connection state +- requested target +- public IP / geolocation lookup + +After `connect`, the intended workflow is: + +1. `nordvpn-client connect ...` +2. `nordvpn-client verify ...` if an explicit location check is needed +3. run the follow-up task such as `web-automation` + +## Limitations + +- Linux city targeting is attempted through the CLI target string and then validated by public IP/location checks. +- macOS app-only fallback cannot guarantee non-interactive control if the app does not expose a CLI. diff --git a/skills/nordvpn-client/SKILL.md b/skills/nordvpn-client/SKILL.md new file mode 100644 index 0000000..4f0eb2b --- /dev/null +++ b/skills/nordvpn-client/SKILL.md @@ -0,0 +1,78 @@ +--- +name: nordvpn-client +description: Use when managing NordVPN on macOS or Linux, including install/bootstrap, login, connect, disconnect, status checks, or verifying a VPN location before running another skill. +--- + +# NordVPN Client + +Cross-platform NordVPN lifecycle management for macOS and Linux hosts. + +## What This Skill Is For + +- Probing whether NordVPN is already installed +- Bootstrapping NordVPN if it is missing +- Logging in through the Linux CLI or the macOS app/CLI path +- Connecting to a country or city before a follow-up action such as `web-automation` +- Disconnecting and checking VPN status +- Verifying public IP and geolocation after connect + +## Command Surface + +```bash +node scripts/nordvpn-client.js status +node scripts/nordvpn-client.js install +node scripts/nordvpn-client.js login +node scripts/nordvpn-client.js verify +node scripts/nordvpn-client.js verify --country "Italy" +node scripts/nordvpn-client.js verify --country "Italy" --city "Milan" +node scripts/nordvpn-client.js connect --country "Italy" +node scripts/nordvpn-client.js connect --city "Milan" +node scripts/nordvpn-client.js connect --country "Italy" --city "Milan" +node scripts/nordvpn-client.js disconnect +``` + +## Platform Notes + +- Linux: + - uses the official `nordvpn` CLI + - install path follows NordVPN's Linux installer + - token login is supported through `NORDVPN_TOKEN` +- macOS: + - prefers Homebrew cask install: `brew install --cask nordvpn` + - prefers a usable `nordvpn` CLI if the installed package exposes one + - otherwise falls back to opening the NordVPN app and guiding the manual login/connect path + +## Credentials + +Do not store secrets in this skill. + +Supported env vars: + +- `NORDVPN_TOKEN` +- `NORDVPN_USERNAME` +- `NORDVPN_PASSWORD` + +Optional credential file env vars: + +- `NORDVPN_TOKEN_FILE` +- `NORDVPN_PASSWORD_FILE` + +## Verification Behavior + +`status`, `verify`, and `connect` report machine-readable JSON including: + +- platform +- install state +- auth state +- connection state +- requested target +- public IP lookup and geolocation + +Use this skill first, then run the follow-up task under the active VPN session. +Use `verify` when you want an explicit post-connect location check without changing VPN state. + +## Known Boundaries + +- Linux country connect is official CLI behavior. +- Linux city connect is attempted through the CLI target string and then validated by post-connect IP/location checks. +- macOS app-only fallback cannot guarantee non-interactive login/connect if the installed app does not expose a CLI. In that case the skill will open the app and return a clear manual-action-required result. diff --git a/skills/nordvpn-client/scripts/nordvpn-client.js b/skills/nordvpn-client/scripts/nordvpn-client.js new file mode 100644 index 0000000..1190b83 --- /dev/null +++ b/skills/nordvpn-client/scripts/nordvpn-client.js @@ -0,0 +1,502 @@ +#!/usr/bin/env node + +const { execFile, spawn } = require("node:child_process"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const https = require("node:https"); + +function printJson(payload, exitCode = 0, errorStream = false) { + const body = `${JSON.stringify(payload, null, 2)}\n`; + (errorStream ? process.stderr : process.stdout).write(body); + process.exit(exitCode); +} + +function usage() { + return { + usage: [ + "node scripts/nordvpn-client.js status", + "node scripts/nordvpn-client.js install", + "node scripts/nordvpn-client.js login", + "node scripts/nordvpn-client.js verify", + 'node scripts/nordvpn-client.js verify --country "Italy"', + 'node scripts/nordvpn-client.js verify --country "Italy" --city "Milan"', + 'node scripts/nordvpn-client.js connect --country "Italy"', + 'node scripts/nordvpn-client.js connect --city "Milan"', + 'node scripts/nordvpn-client.js connect --country "Italy" --city "Milan"', + "node scripts/nordvpn-client.js disconnect", + ], + env: [ + "NORDVPN_TOKEN", + "NORDVPN_TOKEN_FILE", + "NORDVPN_USERNAME", + "NORDVPN_PASSWORD", + "NORDVPN_PASSWORD_FILE", + ], + }; +} + +function parseArgs(argv) { + const args = { _: [] }; + for (let i = 0; i < argv.length; i += 1) { + const token = argv[i]; + if (token.startsWith("--")) { + const key = token.slice(2); + const next = argv[i + 1]; + if (!next || next.startsWith("--")) { + args[key] = true; + } else { + args[key] = next; + i += 1; + } + } else { + args._.push(token); + } + } + return args; +} + +function detectPlatform() { + const raw = os.platform(); + if (raw === "darwin") return "darwin"; + if (raw === "linux") return "linux"; + return raw; +} + +function readSecret(envName, fileEnvName) { + if (process.env[envName]) return process.env[envName]; + const filePath = process.env[fileEnvName]; + if (!filePath) return ""; + try { + return fs.readFileSync(filePath, "utf8").trim(); + } catch { + return ""; + } +} + +function normalizeLocation(value) { + return `${value || ""}`.trim(); +} + +function commandExists(name) { + const pathEntries = `${process.env.PATH || ""}`.split(path.delimiter).filter(Boolean); + for (const entry of pathEntries) { + const full = path.join(entry, name); + try { + fs.accessSync(full, fs.constants.X_OK); + return full; + } catch { + // Continue. + } + } + return ""; +} + +function fileExists(target) { + try { + fs.accessSync(target); + return true; + } catch { + return false; + } +} + +function runExec(command, args = [], options = {}) { + return new Promise((resolve) => { + execFile(command, args, { ...options, encoding: "utf8" }, (error, stdout, stderr) => { + resolve({ + ok: !error, + code: error && typeof error.code === "number" ? error.code : 0, + stdout: stdout || "", + stderr: stderr || "", + error: error ? error.message : "", + }); + }); + }); +} + +function runInteractive(command, args = [], options = {}) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: "inherit", ...options }); + child.on("error", reject); + child.on("exit", (code) => resolve(code === 0)); + }); +} + +function runShell(command) { + return runInteractive("bash", ["-lc", command]); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function fetchJson(url) { + return new Promise((resolve) => { + const req = https.get( + url, + { + headers: { + "User-Agent": "nordvpn-client-skill/1.0", + Accept: "application/json", + }, + }, + (res) => { + let data = ""; + res.on("data", (chunk) => { + data += chunk; + }); + res.on("end", () => { + try { + resolve({ ok: true, statusCode: res.statusCode || 0, json: JSON.parse(data) }); + } catch (error) { + resolve({ + ok: false, + statusCode: res.statusCode || 0, + error: error.message, + raw: data, + }); + } + }); + } + ); + req.on("error", (error) => resolve({ ok: false, error: error.message })); + req.setTimeout(10000, () => { + req.destroy(new Error("timeout")); + }); + }); +} + +async function getPublicIpInfo() { + const lookup = await fetchJson("https://ipapi.co/json/"); + if (!lookup.ok) return { ok: false, error: lookup.error || "lookup failed" }; + + const json = lookup.json || {}; + return { + ok: true, + ip: json.ip || "", + city: json.city || "", + region: json.region || "", + country: json.country_name || json.country || "", + countryCode: json.country_code || "", + org: json.org || "", + }; +} + +async function probeCliStatus(cliPath) { + const status = await runExec(cliPath, ["status"]); + const account = await runExec(cliPath, ["account"]); + const countries = await runExec(cliPath, ["countries"]); + return { + status, + account, + countries, + }; +} + +async function probeInstallation(platform) { + const cliPath = commandExists("nordvpn"); + const appPath = platform === "darwin" ? "/Applications/NordVPN.app" : ""; + const brewPath = platform === "darwin" ? commandExists("brew") : ""; + + let cliProbe = null; + if (cliPath) { + cliProbe = await probeCliStatus(cliPath); + } + + return { + platform, + cliPath, + appPath, + appInstalled: Boolean(appPath && fileExists(appPath)), + brewPath, + installed: Boolean(cliPath) || Boolean(appPath && fileExists(appPath)), + cliProbe, + }; +} + +function inferAuthState(probe) { + if (!probe || !probe.account) return null; + const blob = `${probe.account.stdout}\n${probe.account.stderr}`.toLowerCase(); + if (!blob.trim()) return null; + if (probe.account.ok && !blob.includes("not logged")) return true; + if (blob.includes("not logged") || blob.includes("login")) return false; + return null; +} + +function inferConnectionState(probe) { + if (!probe || !probe.status) return null; + const blob = `${probe.status.stdout}\n${probe.status.stderr}`.toLowerCase(); + if (!blob.trim()) return null; + if (blob.includes("connected")) return true; + if (blob.includes("disconnected") || blob.includes("not connected")) return false; + return null; +} + +function buildStateSummary(installProbe, ipInfo) { + const cliProbe = installProbe.cliProbe; + return { + platform: installProbe.platform, + installed: installProbe.installed, + cliAvailable: Boolean(installProbe.cliPath), + cliPath: installProbe.cliPath || null, + appInstalled: installProbe.appInstalled, + appPath: installProbe.appInstalled ? installProbe.appPath : null, + brewAvailable: Boolean(installProbe.brewPath), + authenticated: inferAuthState(cliProbe), + connected: inferConnectionState(cliProbe), + localStatusRaw: cliProbe && cliProbe.status ? (cliProbe.status.stdout || cliProbe.status.stderr).trim() : "", + publicIp: ipInfo.ok ? ipInfo : null, + }; +} + +async function installNordvpn(installProbe) { + if (installProbe.installed) { + return { changed: false, message: "NordVPN is already installed." }; + } + + if (installProbe.platform === "darwin") { + if (!installProbe.brewPath) { + throw new Error("Homebrew is required on macOS to bootstrap NordVPN via brew cask."); + } + const ok = await runInteractive(installProbe.brewPath, ["install", "--cask", "nordvpn"]); + if (!ok) { + throw new Error("brew install --cask nordvpn failed"); + } + return { changed: true, message: "Installed NordVPN via Homebrew cask." }; + } + + if (installProbe.platform === "linux") { + const command = + "TMP=$(mktemp) && " + + "if command -v curl >/dev/null 2>&1; then curl -fsSL https://downloads.nordcdn.com/apps/linux/install.sh -o \"$TMP\"; " + + "elif command -v wget >/dev/null 2>&1; then wget -qO \"$TMP\" https://downloads.nordcdn.com/apps/linux/install.sh; " + + "else echo 'curl or wget required' >&2; exit 1; fi && sh \"$TMP\" && rm -f \"$TMP\""; + const ok = await runShell(command); + if (!ok) { + throw new Error("NordVPN Linux installer failed"); + } + return { changed: true, message: "Installed NordVPN via the official Linux installer." }; + } + + throw new Error(`Unsupported platform: ${installProbe.platform}`); +} + +async function openMacApp() { + const ok = await runInteractive("open", ["-a", "NordVPN"]); + if (!ok) throw new Error("Failed to open NordVPN.app"); +} + +async function loginNordvpn(installProbe, args) { + const token = readSecret("NORDVPN_TOKEN", "NORDVPN_TOKEN_FILE"); + const username = process.env.NORDVPN_USERNAME || ""; + const password = readSecret("NORDVPN_PASSWORD", "NORDVPN_PASSWORD_FILE"); + + if (installProbe.cliPath) { + if (token) { + const result = await runExec(installProbe.cliPath, ["login", "--token", token]); + if (!result.ok) { + throw new Error((result.stderr || result.stdout || result.error).trim() || "nordvpn login --token failed"); + } + return { mode: "cli-token", message: "Logged in using token." }; + } + + if (username && password && installProbe.platform === "darwin") { + // macOS CLI login support is not documented. Fall back to interactive CLI login. + } + + const ok = await runInteractive(installProbe.cliPath, ["login"]); + if (!ok) throw new Error("nordvpn login failed"); + return { mode: "cli-interactive", message: "Interactive NordVPN login completed." }; + } + + if (installProbe.platform === "darwin" && installProbe.appInstalled) { + await openMacApp(); + return { + mode: "app-manual", + manualActionRequired: true, + message: + "Opened NordVPN.app. Complete login in the app/browser flow, then rerun status or connect. macOS app login is browser-based according to NordVPN support docs.", + }; + } + + throw new Error("NordVPN is not installed."); +} + +function buildConnectTarget(args) { + const country = normalizeLocation(args.country); + const city = normalizeLocation(args.city); + if (!country && !city) { + throw new Error("connect requires --country, --city, or both."); + } + return { country, city }; +} + +function locationMatches(ipInfo, target) { + if (!ipInfo || !ipInfo.ok) return false; + const countryMatch = !target.country || ipInfo.country.toLowerCase().includes(target.country.toLowerCase()); + const cityMatch = !target.city || ipInfo.city.toLowerCase().includes(target.city.toLowerCase()); + return countryMatch && cityMatch; +} + +async function verifyConnection(target) { + const ipInfo = await getPublicIpInfo(); + return { + ok: target ? locationMatches(ipInfo, target) : Boolean(ipInfo && ipInfo.ok), + ipInfo, + }; +} + +async function connectViaCli(cliPath, target) { + const attempts = []; + if (target.city) { + attempts.push([target.city]); + } + if (target.country) { + attempts.push([target.country]); + } + if (target.country && target.city) { + attempts.push([`${target.country} ${target.city}`]); + } + + let lastFailure = ""; + for (const args of attempts) { + const result = await runExec(cliPath, ["connect", ...args]); + if (result.ok) { + return { cliTarget: args.join(" "), raw: result.stdout.trim() || result.stderr.trim() }; + } + lastFailure = (result.stderr || result.stdout || result.error).trim(); + } + + throw new Error(lastFailure || "NordVPN connect failed"); +} + +async function connectViaMacApp(target) { + await openMacApp(); + return { + manualActionRequired: true, + message: `Opened NordVPN.app. Connect manually to ${target.city ? `${target.city}, ` : ""}${target.country || "the requested location"} and rerun status/verify before the follow-up task.`, + }; +} + +async function disconnectNordvpn(installProbe) { + if (installProbe.cliPath) { + const result = await runExec(installProbe.cliPath, ["disconnect"]); + if (!result.ok) { + throw new Error((result.stderr || result.stdout || result.error).trim() || "nordvpn disconnect failed"); + } + return { message: "Disconnected from NordVPN." }; + } + + if (installProbe.platform === "darwin" && installProbe.appInstalled) { + await openMacApp(); + return { + manualActionRequired: true, + message: "Opened NordVPN.app. Disconnect manually because no usable CLI was detected on macOS.", + }; + } + + throw new Error("NordVPN is not installed."); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const action = args._[0]; + if (!action || args.help) { + printJson(usage(), action ? 0 : 1, !action); + } + + const platform = detectPlatform(); + if (!["darwin", "linux"].includes(platform)) { + printJson({ error: `Unsupported platform: ${platform}` }, 1, true); + } + + const installProbe = await probeInstallation(platform); + + try { + if (action === "status") { + const ipInfo = await getPublicIpInfo(); + printJson(buildStateSummary(installProbe, ipInfo)); + } + + if (action === "install") { + const result = await installNordvpn(installProbe); + const refreshed = await probeInstallation(platform); + printJson({ + action, + ...result, + state: buildStateSummary(refreshed, await getPublicIpInfo()), + }); + } + + if (action === "login") { + const result = await loginNordvpn(installProbe, args); + const refreshed = await probeInstallation(platform); + printJson({ + action, + ...result, + state: buildStateSummary(refreshed, await getPublicIpInfo()), + }); + } + + if (action === "verify") { + const target = args.country || args.city ? buildConnectTarget(args) : null; + const verified = await verifyConnection(target); + const refreshed = await probeInstallation(platform); + printJson({ + action, + requestedTarget: target, + verified: verified.ok, + verification: verified.ipInfo, + state: buildStateSummary(refreshed, verified.ipInfo), + }, verified.ok ? 0 : 1, !verified.ok); + } + + if (action === "connect") { + const target = buildConnectTarget(args); + if (!installProbe.installed) { + throw new Error("NordVPN is not installed."); + } + let connectResult; + if (installProbe.cliPath) { + connectResult = await connectViaCli(installProbe.cliPath, target); + } else if (platform === "darwin" && installProbe.appInstalled) { + connectResult = await connectViaMacApp(target); + } else { + throw new Error("No usable NordVPN control path found."); + } + + if (connectResult.manualActionRequired) { + printJson({ action, requestedTarget: target, ...connectResult }); + } + + await sleep(3000); + const verified = await verifyConnection(target); + const refreshed = await probeInstallation(platform); + printJson({ + action, + requestedTarget: target, + connectResult, + verified: verified.ok, + verification: verified.ipInfo, + state: buildStateSummary(refreshed, verified.ipInfo), + }, verified.ok ? 0 : 1, !verified.ok); + } + + if (action === "disconnect") { + const result = await disconnectNordvpn(installProbe); + const refreshed = await probeInstallation(platform); + printJson({ + action, + ...result, + state: buildStateSummary(refreshed, await getPublicIpInfo()), + }); + } + + printJson({ error: `Unknown action: ${action}`, ...usage() }, 1, true); + } catch (error) { + printJson({ error: error.message || String(error), action }, 1, true); + } +} + +main();