warpmetal 0.1.0

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.
package/README.md ADDED
@@ -0,0 +1,75 @@
1
+ # WarpMetal Agent Kit
2
+
3
+ The official command-line client and portable Agent Skill for WarpMetal.
4
+
5
+ The CLI uses the public API at `https://api.warpmetal.com`, stores generated
6
+ WarpMetal credentials in a user-private state file, and never reads or stores
7
+ wallet private keys or SSH private-key contents.
8
+
9
+ ## Distribution
10
+
11
+ - Source and releases: `https://github.com/warpmetal/agent-kit`
12
+ - CLI: the unscoped public npm package `warpmetal`, exposing the `warpmetal`
13
+ executable
14
+ - Skill: `skills/warpmetal` in the GitHub repository and bundled inside the
15
+ npm package
16
+
17
+ Keeping the skill beside the CLI gives Codex, Claude, and other Agent
18
+ Skills-compatible tools one canonical set of safety instructions while the CLI
19
+ remains the stable executable API.
20
+
21
+ ## Install
22
+
23
+ ```sh
24
+ npm install --global warpmetal
25
+ warpmetal --help
26
+ ```
27
+
28
+ Install the bundled skill for supported coding agents:
29
+
30
+ ```sh
31
+ warpmetal agent install --target codex
32
+ warpmetal agent install --target claude
33
+ warpmetal agent install --target all
34
+ ```
35
+
36
+ Use `--scope project` to install into the current repository instead of the
37
+ user-level agent directory.
38
+
39
+ The bundled `skills/warpmetal` directory follows the portable Agent Skills
40
+ layout. Agents that support that layout but use another installation path can
41
+ consume that directory directly; they do not need a different WarpMetal API
42
+ integration.
43
+
44
+ ## First commands
45
+
46
+ ```sh
47
+ warpmetal health
48
+ warpmetal catalog
49
+ warpmetal order prepare \
50
+ --plan agent \
51
+ --hostname codex-workspace \
52
+ --os '<exact name from warpmetal catalog>' \
53
+ --ssh-public-key-file ~/.ssh/id_ed25519.pub
54
+ ```
55
+
56
+ Pass `--json` for structured, secret-redacted output. Use
57
+ `WARPMETAL_API_URL` for an alternate API origin and `WARPMETAL_HOME` for an
58
+ alternate state directory.
59
+
60
+ ## Security boundary
61
+
62
+ - Order and access tokens are never printed; they are written to
63
+ `${WARPMETAL_HOME:-~/.config/warpmetal}/state.json` with user-only
64
+ permissions where the platform supports POSIX modes.
65
+ - The CLI passes an SSH private-key path directly to `ssh-keygen`; it never
66
+ reads the private key.
67
+ - The CLI accepts an externally produced x402 `PAYMENT-SIGNATURE` from a file.
68
+ Wallet key management and signing remain outside this package.
69
+ - Destructive or state-changing commands require explicit confirmations and
70
+ generate idempotency keys by default.
71
+
72
+ ## Publishing status
73
+
74
+ This package is an initial development release. Choose the public-source
75
+ license and replace `UNLICENSED` before publishing it to npm.
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { main } from "../src/cli.js";
4
+
5
+ const exitCode = await main(process.argv.slice(2));
6
+ process.exitCode = exitCode;
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "warpmetal",
3
+ "version": "0.1.0",
4
+ "description": "Agent-safe CLI and skill for purchasing and managing WarpMetal VPS servers",
5
+ "type": "module",
6
+ "bin": {
7
+ "warpmetal": "./bin/warpmetal.js"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "src/",
12
+ "skills/warpmetal/",
13
+ "README.md"
14
+ ],
15
+ "engines": {
16
+ "node": ">=20.0.0"
17
+ },
18
+ "scripts": {
19
+ "check": "node --check bin/warpmetal.js && node --check src/api.js && node --check src/args.js && node --check src/cli.js && node --check src/errors.js && node --check src/install-skill.js && node --check src/ssh.js && node --check src/state.js",
20
+ "test": "node --test",
21
+ "prepack": "npm run check && npm test"
22
+ },
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/warpmetal/agent-kit.git"
29
+ },
30
+ "homepage": "https://github.com/warpmetal/agent-kit#readme",
31
+ "bugs": {
32
+ "url": "https://github.com/warpmetal/agent-kit/issues"
33
+ },
34
+ "license": "UNLICENSED",
35
+ "keywords": [
36
+ "warpmetal",
37
+ "vps",
38
+ "agent-skills",
39
+ "codex",
40
+ "claude",
41
+ "x402"
42
+ ]
43
+ }
@@ -0,0 +1,111 @@
1
+ ---
2
+ name: warpmetal
3
+ description: Safely purchase and manage WarpMetal VPS servers with the warpmetal CLI. Use when Codex, Claude Code, Cursor, Windsurf, or another shell-capable agent needs to inspect live VPS plans and operating systems, prepare or pay for an x402 order, poll provisioning, prove ownership with an SSH key, inspect a server, or run supported lifecycle operations.
4
+ ---
5
+
6
+ # WarpMetal
7
+
8
+ Use the `warpmetal` CLI as the executable interface. Do not reconstruct its
9
+ credential, idempotency, exact-body retry, SSH signing, or polling behavior
10
+ with ad hoc HTTP commands.
11
+
12
+ ## Start safely
13
+
14
+ 1. Run `warpmetal --version`.
15
+ 2. If it is missing, ask before installing software, then install the official
16
+ npm package only from `https://www.npmjs.com/package/warpmetal`.
17
+ 3. Use `--json` for every agent-driven command.
18
+ 4. Read [references/safety.md](references/safety.md) before preparing an order,
19
+ authorizing payment, using an SSH identity, or changing a server.
20
+ 5. Read [references/cli-reference.md](references/cli-reference.md) when choosing
21
+ a command or interpreting an exit code.
22
+
23
+ Never read, print, summarize, upload, or commit the WarpMetal state file. Never
24
+ read an SSH private-key file. Pass its path only to a command designed to use
25
+ it. Never request or handle a wallet seed phrase or private key.
26
+
27
+ ## Discover before acting
28
+
29
+ Run:
30
+
31
+ ```sh
32
+ warpmetal health --json
33
+ warpmetal catalog --json
34
+ ```
35
+
36
+ Stop if `purchasingReady` is false. Select `planId` and the exact OS `name`
37
+ from the live catalog. Do not reuse an OS name or price from documentation or
38
+ a previous session.
39
+
40
+ ## Prepare an order
41
+
42
+ Confirm the intended plan, exact OS, hostname, and existing SSH public-key file
43
+ with the user. Ask before generating a new SSH key pair.
44
+
45
+ ```sh
46
+ warpmetal order prepare \
47
+ --plan <planId> \
48
+ --hostname <hostname> \
49
+ --os '<exact live OS name>' \
50
+ --ssh-public-key-file <public-key-path> \
51
+ --json
52
+ ```
53
+
54
+ The CLI saves the generated recovery credential privately and does not print
55
+ it. Preserve the reported task and server IDs in the conversation, but do not
56
+ open the state file to retrieve the credential.
57
+
58
+ ## Authorize payment
59
+
60
+ Run `warpmetal checkout challenge --task <taskId> --json` to obtain the live
61
+ x402 terms. Before any wallet creation, funding, or signature, show the user
62
+ the exact amount, asset, network, recipient, and expiration derived from the
63
+ live challenge and obtain explicit approval.
64
+
65
+ Use a compatible external wallet signer to produce one `PAYMENT-SIGNATURE`
66
+ header value in a file. Do not pass wallet secrets to WarpMetal or the CLI.
67
+ After approval, submit it with:
68
+
69
+ ```sh
70
+ warpmetal checkout submit \
71
+ --task <taskId> \
72
+ --payment-signature-file <path> \
73
+ --wait \
74
+ --json
75
+ ```
76
+
77
+ If the command reports a rejected signature, inspect the replacement live
78
+ challenge and request new approval where its terms changed. If it reports
79
+ `manual_review`, stop immediately and never create another payment.
80
+
81
+ ## Provision and manage
82
+
83
+ Poll a prepared or paid order with:
84
+
85
+ ```sh
86
+ warpmetal order status --task <taskId> --wait --json
87
+ ```
88
+
89
+ For routine management, prove possession of the installed SSH key without
90
+ reading it:
91
+
92
+ ```sh
93
+ warpmetal server login --server <serverId> --identity <private-key-path> --json
94
+ warpmetal server get --server <serverId> --json
95
+ ```
96
+
97
+ For power changes, state the intended effect and obtain explicit approval,
98
+ then pass the same action as the confirmation:
99
+
100
+ ```sh
101
+ warpmetal server power \
102
+ --server <serverId> \
103
+ --action reboot \
104
+ --confirm reboot \
105
+ --wait \
106
+ --json
107
+ ```
108
+
109
+ Do not fall back to raw API calls for reload, deletion, networking, renewal,
110
+ or another unsupported mutation. Explain that the installed CLI version does
111
+ not yet expose that guarded operation.
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "WarpMetal VPS"
3
+ short_description: "Purchase and manage WarpMetal VPS servers"
4
+ default_prompt: "Use $warpmetal to choose, purchase, and manage a WarpMetal VPS safely."
@@ -0,0 +1,100 @@
1
+ # WarpMetal CLI reference
2
+
3
+ ## Contents
4
+
5
+ - Discovery
6
+ - Purchase and provisioning
7
+ - Server management
8
+ - Skill installation and state
9
+ - Exit codes
10
+
11
+ ## Discovery
12
+
13
+ ```sh
14
+ warpmetal health --json
15
+ warpmetal catalog [--plan <planId>] --json
16
+ ```
17
+
18
+ `health` exits with code 3 when the service responds but purchasing is paused.
19
+ The catalog remains useful for read-only discovery.
20
+
21
+ ## Purchase and provisioning
22
+
23
+ ```sh
24
+ warpmetal order prepare \
25
+ --plan <live planId> \
26
+ --hostname <dns-label> \
27
+ --os '<exact live OS name>' \
28
+ --ssh-public-key-file <path> \
29
+ [--email <address>] \
30
+ [--idempotency-key <key>] \
31
+ --json
32
+
33
+ warpmetal checkout challenge --task <taskId> --json
34
+
35
+ warpmetal checkout submit \
36
+ --task <taskId> \
37
+ --payment-signature-file <path> \
38
+ [--wait] [--timeout-seconds <n>] \
39
+ --json
40
+
41
+ warpmetal order status \
42
+ --task <taskId> \
43
+ [--wait] [--timeout-seconds <n>] \
44
+ --json
45
+ ```
46
+
47
+ The payment signature file must contain one HTTP header value. The CLI does
48
+ not create or store wallet keys and does not sign x402 challenges.
49
+
50
+ ## Server management
51
+
52
+ ```sh
53
+ warpmetal server login \
54
+ --server <serverId> \
55
+ --identity <private-key-path> \
56
+ --json
57
+
58
+ warpmetal server get --server <serverId> --json
59
+
60
+ warpmetal server power \
61
+ --server <serverId> \
62
+ --action <boot|reboot|shutdown> \
63
+ --confirm <same-action> \
64
+ [--idempotency-key <key>] \
65
+ [--wait] [--timeout-seconds <n>] \
66
+ --json
67
+
68
+ warpmetal operation get \
69
+ --operation <operationId> \
70
+ [--server <serverId>] \
71
+ [--wait] [--timeout-seconds <n>] \
72
+ --json
73
+ ```
74
+
75
+ Use `--token-file` only for recovery when local state is unavailable. Prefer
76
+ `WARPMETAL_OWNER_TOKEN` or `WARPMETAL_ACCESS_TOKEN` for a single command over a
77
+ shell argument, because command-line arguments can be recorded in history and
78
+ process listings.
79
+
80
+ ## Skill installation and state
81
+
82
+ ```sh
83
+ warpmetal agent install --target <codex|claude|all> [--scope user|project]
84
+ warpmetal state list --json
85
+ ```
86
+
87
+ `state list` returns identifiers and credential-presence booleans only. Never
88
+ open the underlying state file from an agent session.
89
+
90
+ ## Exit codes
91
+
92
+ - `0`: command completed or reached its requested safe stopping point.
93
+ - `1`: unexpected local or API failure.
94
+ - `2`: invalid command, option, input, or local state.
95
+ - `3`: purchasing unavailable, rate limited, or API temporarily unavailable.
96
+ - `4`: missing or rejected credential or SSH proof.
97
+ - `5`: API conflict, including an idempotency conflict.
98
+ - `6`: manual review; stop and do not retry the consequential action.
99
+ - `7`: payment authorization rejected or required; inspect the live challenge.
100
+ - `8`: operation still pending or wait timeout reached.
@@ -0,0 +1,55 @@
1
+ # WarpMetal safety rules
2
+
3
+ ## Contents
4
+
5
+ - Authority and freshness
6
+ - Credential boundaries
7
+ - Required confirmations
8
+ - Retry and terminal-state rules
9
+
10
+ ## Authority and freshness
11
+
12
+ - Treat `https://warpmetal.com/llms.txt`, the live catalog, and the live HTTP
13
+ 402 challenge as authoritative in that order.
14
+ - Stop new purchases when `warpmetal health --json` reports
15
+ `purchasingReady: false`.
16
+ - Select an exact OS name from the chosen plan's current
17
+ `operatingSystems[]`. Never guess or hard-code an image version.
18
+
19
+ ## Credential boundaries
20
+
21
+ - Treat the generated `ownerToken` as an offline recovery credential. Let the
22
+ CLI store it; never inspect the state file or include it in output.
23
+ - Treat an SSH-derived access token as a short-lived bearer credential. Let
24
+ the CLI store and refresh it.
25
+ - Never read or transmit an SSH private key. Pass only its filesystem path to
26
+ `warpmetal server login` or `ssh-keygen`.
27
+ - Never request, read, transmit, or store a wallet seed phrase or private key.
28
+ - Never put a token, payment signature, private key, or state-file content in
29
+ a prompt, URL, log, screenshot, source file, or shell argument.
30
+
31
+ ## Required confirmations
32
+
33
+ Obtain explicit user approval immediately before:
34
+
35
+ - installing the CLI or skill;
36
+ - generating a new SSH key pair;
37
+ - creating or funding a wallet;
38
+ - signing or submitting an x402 payment authorization;
39
+ - booting, rebooting, or shutting down a server; and
40
+ - any destructive reload, deletion, or replacement-key operation.
41
+
42
+ An order preparation is unpaid but consumes a limited prepared-order slot.
43
+ Confirm the plan, hostname, OS, and public key before preparing it.
44
+
45
+ ## Retry and terminal-state rules
46
+
47
+ - Reuse the same idempotency key only for the exact same logical request.
48
+ - For `payment_pending`, retry the exact checkout body and the exact same
49
+ payment signature. Do not create a replacement payment.
50
+ - A signed HTTP 402 rejects that signature. Use the new live challenge for one
51
+ replacement authorization.
52
+ - Treat `manual_review` as terminal. Do not pay again and do not repeat the
53
+ mutation.
54
+ - Treat HTTP 202 as accepted or pending, never as proof of success. Poll the
55
+ returned task or operation until a documented terminal state.
package/src/api.js ADDED
@@ -0,0 +1,170 @@
1
+ import { ApiError, CliError } from "./errors.js";
2
+
3
+ const DEFAULT_API_URL = "https://api.warpmetal.com";
4
+
5
+ function validateBaseUrl(value) {
6
+ let url;
7
+ try {
8
+ url = new URL(value);
9
+ } catch {
10
+ throw new CliError(`Invalid WarpMetal API URL: ${value}`, { exitCode: 2 });
11
+ }
12
+ const local = ["localhost", "127.0.0.1", "::1"].includes(url.hostname);
13
+ if (url.protocol !== "https:" && !(local && url.protocol === "http:")) {
14
+ throw new CliError("WarpMetal API URLs must use HTTPS (HTTP is allowed for localhost).", {
15
+ exitCode: 2,
16
+ });
17
+ }
18
+ if (url.username || url.password || url.search || url.hash) {
19
+ throw new CliError("WarpMetal API URLs cannot contain credentials, query, or fragment data.", {
20
+ exitCode: 2,
21
+ });
22
+ }
23
+ return url.toString().replace(/\/$/, "");
24
+ }
25
+
26
+ function parseResponseBody(text) {
27
+ if (!text) return null;
28
+ try {
29
+ return JSON.parse(text);
30
+ } catch {
31
+ return { message: text };
32
+ }
33
+ }
34
+
35
+ function headerMap(headers) {
36
+ return Object.fromEntries(headers.entries());
37
+ }
38
+
39
+ export class WarpMetalClient {
40
+ constructor({ baseUrl, fetchImpl = globalThis.fetch, timeoutMs = 30_000 } = {}) {
41
+ if (typeof fetchImpl !== "function") {
42
+ throw new CliError("This Node.js runtime does not provide fetch().", { exitCode: 2 });
43
+ }
44
+ this.baseUrl = validateBaseUrl(baseUrl || process.env.WARPMETAL_API_URL || DEFAULT_API_URL);
45
+ this.fetchImpl = fetchImpl;
46
+ this.timeoutMs = timeoutMs;
47
+ }
48
+
49
+ async request(
50
+ method,
51
+ path,
52
+ {
53
+ body,
54
+ bodyText,
55
+ token,
56
+ idempotencyKey,
57
+ paymentSignature,
58
+ acceptStatuses = [],
59
+ } = {},
60
+ ) {
61
+ const url = new URL(path, `${this.baseUrl}/`);
62
+ const headers = {
63
+ Accept: "application/json",
64
+ "User-Agent": "warpmetal-cli/0.1.0",
65
+ };
66
+ let requestBody;
67
+ if (bodyText !== undefined) {
68
+ requestBody = bodyText;
69
+ headers["Content-Type"] = "application/json";
70
+ } else if (body !== undefined) {
71
+ requestBody = JSON.stringify(body);
72
+ headers["Content-Type"] = "application/json";
73
+ }
74
+ if (token) headers.Authorization = `Bearer ${token}`;
75
+ if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
76
+ if (paymentSignature) headers["PAYMENT-SIGNATURE"] = paymentSignature;
77
+
78
+ let response;
79
+ try {
80
+ response = await this.fetchImpl(url, {
81
+ method,
82
+ headers,
83
+ body: requestBody,
84
+ signal: AbortSignal.timeout(this.timeoutMs),
85
+ });
86
+ } catch (error) {
87
+ const message = error instanceof Error ? error.message : String(error);
88
+ throw new CliError(`Could not reach ${url.origin}: ${message}`, { exitCode: 3 });
89
+ }
90
+
91
+ const text = await response.text();
92
+ const data = parseResponseBody(text);
93
+ const result = {
94
+ status: response.status,
95
+ data,
96
+ headers: headerMap(response.headers),
97
+ bodyText: text,
98
+ };
99
+ if (response.ok || acceptStatuses.includes(response.status)) return result;
100
+
101
+ const apiError = data?.error;
102
+ throw new ApiError(apiError?.message || `Request failed with HTTP ${response.status}.`, {
103
+ status: response.status,
104
+ code: apiError?.code,
105
+ retryAfter: response.headers.get("retry-after") || undefined,
106
+ body: data,
107
+ });
108
+ }
109
+
110
+ health() {
111
+ return this.request("GET", "/api/health");
112
+ }
113
+
114
+ catalog() {
115
+ return this.request("GET", "/api/catalog");
116
+ }
117
+
118
+ prepareOrder(body, idempotencyKey) {
119
+ return this.request("POST", "/api/orders", { body, idempotencyKey });
120
+ }
121
+
122
+ getTask(taskId, token) {
123
+ return this.request("GET", `/api/tasks/${encodeURIComponent(taskId)}`, { token });
124
+ }
125
+
126
+ checkout(path, { bodyText, token, paymentSignature }) {
127
+ return this.request("POST", path, {
128
+ bodyText,
129
+ token,
130
+ paymentSignature,
131
+ acceptStatuses: [402, 409],
132
+ });
133
+ }
134
+
135
+ issueSshChallenge(serverId) {
136
+ return this.request(
137
+ "POST",
138
+ `/api/servers/${encodeURIComponent(serverId)}/auth/challenges`,
139
+ { body: {} },
140
+ );
141
+ }
142
+
143
+ exchangeSshChallenge(serverId, challengeId, signature) {
144
+ return this.request(
145
+ "POST",
146
+ `/api/servers/${encodeURIComponent(serverId)}/auth/tokens`,
147
+ { body: { challengeId, signature } },
148
+ );
149
+ }
150
+
151
+ getServer(serverId, token) {
152
+ return this.request("GET", `/api/servers/${encodeURIComponent(serverId)}`, { token });
153
+ }
154
+
155
+ powerServer(serverId, action, token, idempotencyKey) {
156
+ return this.request("POST", `/api/servers/${encodeURIComponent(serverId)}/power`, {
157
+ body: { action },
158
+ token,
159
+ idempotencyKey,
160
+ });
161
+ }
162
+
163
+ getOperation(operationId, token) {
164
+ return this.request("GET", `/api/operations/${encodeURIComponent(operationId)}`, {
165
+ token,
166
+ });
167
+ }
168
+ }
169
+
170
+ export { DEFAULT_API_URL };
package/src/args.js ADDED
@@ -0,0 +1,78 @@
1
+ import { CliError } from "./errors.js";
2
+
3
+ export function parseArguments(argv) {
4
+ const positionals = [];
5
+ const options = {};
6
+
7
+ for (let index = 0; index < argv.length; index += 1) {
8
+ const value = argv[index];
9
+ if (value === "--") {
10
+ positionals.push(...argv.slice(index + 1));
11
+ break;
12
+ }
13
+ if (!value.startsWith("--")) {
14
+ positionals.push(value);
15
+ continue;
16
+ }
17
+
18
+ if (value.startsWith("--no-")) {
19
+ options[value.slice(5)] = false;
20
+ continue;
21
+ }
22
+
23
+ const equals = value.indexOf("=");
24
+ if (equals !== -1) {
25
+ options[value.slice(2, equals)] = value.slice(equals + 1);
26
+ continue;
27
+ }
28
+
29
+ const name = value.slice(2);
30
+ const next = argv[index + 1];
31
+ if (next !== undefined && !next.startsWith("--")) {
32
+ options[name] = next;
33
+ index += 1;
34
+ } else {
35
+ options[name] = true;
36
+ }
37
+ }
38
+
39
+ return { positionals, options };
40
+ }
41
+
42
+ export function stringOption(options, name, { required = false } = {}) {
43
+ const value = options[name];
44
+ if (value === undefined && !required) return undefined;
45
+ if (typeof value !== "string" || value.length === 0) {
46
+ throw new CliError(`--${name} requires a value.`, { exitCode: 2 });
47
+ }
48
+ return value;
49
+ }
50
+
51
+ export function booleanOption(options, name) {
52
+ const value = options[name];
53
+ if (value === undefined) return false;
54
+ if (typeof value !== "boolean") {
55
+ throw new CliError(`--${name} does not take a value.`, { exitCode: 2 });
56
+ }
57
+ return value;
58
+ }
59
+
60
+ export function integerOption(options, name, fallback) {
61
+ const value = options[name];
62
+ if (value === undefined) return fallback;
63
+ if (typeof value !== "string" || !/^[1-9]\d*$/.test(value)) {
64
+ throw new CliError(`--${name} must be a positive integer.`, { exitCode: 2 });
65
+ }
66
+ const number = Number(value);
67
+ if (!Number.isSafeInteger(number)) {
68
+ throw new CliError(`--${name} is too large.`, { exitCode: 2 });
69
+ }
70
+ return number;
71
+ }
72
+
73
+ export function rejectUnknownOptions(options, allowed) {
74
+ const unknown = Object.keys(options).filter((name) => !allowed.includes(name));
75
+ if (unknown.length > 0) {
76
+ throw new CliError(`Unknown option: --${unknown[0]}`, { exitCode: 2 });
77
+ }
78
+ }