htmlship 0.1.1
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/LICENSE +21 -0
- package/README.md +47 -0
- package/dist/cli.js +635 -0
- package/package.json +56 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 HTMLShip
|
|
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# htmlship
|
|
2
|
+
|
|
3
|
+
CLI + MCP server for [HTMLShip](https://htmlship.com) — host and share HTML pages from LLMs and coding agents in one line.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
# Publish an HTML file
|
|
7
|
+
npx htmlship publish report.html
|
|
8
|
+
|
|
9
|
+
# Pipe from stdin
|
|
10
|
+
cat report.html | npx htmlship publish -
|
|
11
|
+
|
|
12
|
+
# Inspect / update / delete using the saved owner key
|
|
13
|
+
npx htmlship get <slug>
|
|
14
|
+
npx htmlship update <slug> updated.html
|
|
15
|
+
npx htmlship delete <slug>
|
|
16
|
+
npx htmlship list-mine
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## MCP server
|
|
20
|
+
|
|
21
|
+
`htmlship mcp` starts a stdio MCP server with three tools: `publish_html`, `fetch_html`, `update_html`.
|
|
22
|
+
|
|
23
|
+
### Claude Desktop / Claude Code / Cursor
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{
|
|
27
|
+
"mcpServers": {
|
|
28
|
+
"htmlship": {
|
|
29
|
+
"command": "npx",
|
|
30
|
+
"args": ["-y", "htmlship", "mcp"]
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
That's the whole install. Restart your MCP client and ask the agent to publish HTML.
|
|
37
|
+
|
|
38
|
+
## Configuration
|
|
39
|
+
|
|
40
|
+
- `HTMLSHIP_API_URL` — API base URL (default `https://api.htmlship.com`)
|
|
41
|
+
- `HTMLSHIP_KEYS_DIR` — directory for the owner-key store (default `~/.htmlship`)
|
|
42
|
+
|
|
43
|
+
The owner-key store at `~/.htmlship/keys.json` is **format-compatible** with the [Python CLI](https://pypi.org/project/htmlship/). You can publish from one and update/delete from the other.
|
|
44
|
+
|
|
45
|
+
## License
|
|
46
|
+
|
|
47
|
+
MIT
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __esm = (fn, res) => function __init() {
|
|
5
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
6
|
+
};
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// src/errors.ts
|
|
13
|
+
var HTMLShipError, NotFoundError, AuthError, RateLimitError, APIError;
|
|
14
|
+
var init_errors = __esm({
|
|
15
|
+
"src/errors.ts"() {
|
|
16
|
+
"use strict";
|
|
17
|
+
HTMLShipError = class extends Error {
|
|
18
|
+
constructor(message) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = "HTMLShipError";
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
NotFoundError = class extends HTMLShipError {
|
|
24
|
+
constructor(message = "not found") {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = "NotFoundError";
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
AuthError = class extends HTMLShipError {
|
|
30
|
+
constructor(message = "unauthorized") {
|
|
31
|
+
super(message);
|
|
32
|
+
this.name = "AuthError";
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
RateLimitError = class extends HTMLShipError {
|
|
36
|
+
retryAfter;
|
|
37
|
+
constructor(message, retryAfter = null) {
|
|
38
|
+
super(message);
|
|
39
|
+
this.name = "RateLimitError";
|
|
40
|
+
this.retryAfter = retryAfter;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
APIError = class extends HTMLShipError {
|
|
44
|
+
statusCode;
|
|
45
|
+
constructor(message, statusCode = null) {
|
|
46
|
+
super(message);
|
|
47
|
+
this.name = "APIError";
|
|
48
|
+
this.statusCode = statusCode;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// src/version.ts
|
|
55
|
+
var VERSION;
|
|
56
|
+
var init_version = __esm({
|
|
57
|
+
"src/version.ts"() {
|
|
58
|
+
"use strict";
|
|
59
|
+
VERSION = "0.1.1";
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// src/client.ts
|
|
64
|
+
var DEFAULT_API_URL, HTMLShipClient;
|
|
65
|
+
var init_client = __esm({
|
|
66
|
+
"src/client.ts"() {
|
|
67
|
+
"use strict";
|
|
68
|
+
init_errors();
|
|
69
|
+
init_version();
|
|
70
|
+
DEFAULT_API_URL = "https://api.htmlship.com";
|
|
71
|
+
HTMLShipClient = class {
|
|
72
|
+
baseUrl;
|
|
73
|
+
apiKey;
|
|
74
|
+
timeoutMs;
|
|
75
|
+
fetchImpl;
|
|
76
|
+
constructor(options = {}) {
|
|
77
|
+
const envUrl = process.env["HTMLSHIP_API_URL"];
|
|
78
|
+
const envKey = process.env["HTMLSHIP_API_KEY"];
|
|
79
|
+
this.baseUrl = (options.baseUrl ?? envUrl ?? DEFAULT_API_URL).replace(/\/+$/, "");
|
|
80
|
+
this.apiKey = options.apiKey ?? envKey ?? null;
|
|
81
|
+
this.timeoutMs = options.timeoutMs ?? 3e4;
|
|
82
|
+
this.fetchImpl = options.fetchImpl ?? globalThis.fetch.bind(globalThis);
|
|
83
|
+
}
|
|
84
|
+
async publish(html, options = {}) {
|
|
85
|
+
const body = {
|
|
86
|
+
html,
|
|
87
|
+
sandbox_mode: options.sandboxMode ?? "strict"
|
|
88
|
+
};
|
|
89
|
+
if (options.title != null) body["title"] = options.title;
|
|
90
|
+
if (options.password != null) body["password"] = options.password;
|
|
91
|
+
if (options.expiresIn != null) body["expires_in"] = options.expiresIn;
|
|
92
|
+
if (options.parentSlug != null) body["parent_slug"] = options.parentSlug;
|
|
93
|
+
return await this.request("POST", "/api/v1/pastes", { body });
|
|
94
|
+
}
|
|
95
|
+
async get(slug) {
|
|
96
|
+
return await this.request("GET", `/api/v1/pastes/${encodeURIComponent(slug)}`);
|
|
97
|
+
}
|
|
98
|
+
async update(slug, html, ownerKey, options = {}) {
|
|
99
|
+
const body = { html };
|
|
100
|
+
if (options.title != null) body["title"] = options.title;
|
|
101
|
+
return await this.request(
|
|
102
|
+
"PATCH",
|
|
103
|
+
`/api/v1/pastes/${encodeURIComponent(slug)}`,
|
|
104
|
+
{ body, headers: { "X-Owner-Key": ownerKey } }
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
async delete(slug, ownerKey) {
|
|
108
|
+
await this.request(
|
|
109
|
+
"DELETE",
|
|
110
|
+
`/api/v1/pastes/${encodeURIComponent(slug)}`,
|
|
111
|
+
{ headers: { "X-Owner-Key": ownerKey }, expectNoBody: true }
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
async createVersion(parentSlug, html, options = {}) {
|
|
115
|
+
const body = {
|
|
116
|
+
html,
|
|
117
|
+
sandbox_mode: options.sandboxMode ?? "strict"
|
|
118
|
+
};
|
|
119
|
+
if (options.title != null) body["title"] = options.title;
|
|
120
|
+
if (options.password != null) body["password"] = options.password;
|
|
121
|
+
if (options.expiresIn != null) body["expires_in"] = options.expiresIn;
|
|
122
|
+
return await this.request(
|
|
123
|
+
"POST",
|
|
124
|
+
`/api/v1/pastes/${encodeURIComponent(parentSlug)}/version`,
|
|
125
|
+
{ body }
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
// --- private ------------------------------------------------------------
|
|
129
|
+
async request(method, path, options = {}) {
|
|
130
|
+
const headers = {
|
|
131
|
+
"User-Agent": `htmlship-node/${VERSION}`,
|
|
132
|
+
...options.headers ?? {}
|
|
133
|
+
};
|
|
134
|
+
if (this.apiKey) headers["Authorization"] = `Bearer ${this.apiKey}`;
|
|
135
|
+
if (options.body !== void 0) headers["Content-Type"] = "application/json";
|
|
136
|
+
const controller = new AbortController();
|
|
137
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
138
|
+
let response;
|
|
139
|
+
try {
|
|
140
|
+
response = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
141
|
+
method,
|
|
142
|
+
headers,
|
|
143
|
+
body: options.body !== void 0 ? JSON.stringify(options.body) : void 0,
|
|
144
|
+
signal: controller.signal
|
|
145
|
+
});
|
|
146
|
+
} catch (err) {
|
|
147
|
+
clearTimeout(timer);
|
|
148
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
149
|
+
throw new HTMLShipError(`request timed out after ${this.timeoutMs}ms`);
|
|
150
|
+
}
|
|
151
|
+
throw new HTMLShipError(`network error: ${err.message}`);
|
|
152
|
+
}
|
|
153
|
+
clearTimeout(timer);
|
|
154
|
+
if (response.ok) {
|
|
155
|
+
if (options.expectNoBody) return void 0;
|
|
156
|
+
const text = await response.text();
|
|
157
|
+
if (!text) return void 0;
|
|
158
|
+
return JSON.parse(text);
|
|
159
|
+
}
|
|
160
|
+
const detail = await this.extractErrorDetail(response);
|
|
161
|
+
if (response.status === 404) throw new NotFoundError(detail);
|
|
162
|
+
if (response.status === 401 || response.status === 403) throw new AuthError(detail);
|
|
163
|
+
if (response.status === 429) {
|
|
164
|
+
const retryHeader = response.headers.get("Retry-After");
|
|
165
|
+
const retryAfter = retryHeader ? Number.parseFloat(retryHeader) : null;
|
|
166
|
+
throw new RateLimitError(detail, Number.isFinite(retryAfter) ? retryAfter : null);
|
|
167
|
+
}
|
|
168
|
+
throw new APIError(detail, response.status);
|
|
169
|
+
}
|
|
170
|
+
async extractErrorDetail(response) {
|
|
171
|
+
try {
|
|
172
|
+
const text = await response.text();
|
|
173
|
+
if (!text) return `HTTP ${response.status}`;
|
|
174
|
+
try {
|
|
175
|
+
const data = JSON.parse(text);
|
|
176
|
+
if (data && typeof data === "object" && "detail" in data) {
|
|
177
|
+
return String(data.detail);
|
|
178
|
+
}
|
|
179
|
+
return text;
|
|
180
|
+
} catch {
|
|
181
|
+
return text;
|
|
182
|
+
}
|
|
183
|
+
} catch {
|
|
184
|
+
return `HTTP ${response.status}`;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// src/mcp/server.ts
|
|
192
|
+
var server_exports = {};
|
|
193
|
+
__export(server_exports, {
|
|
194
|
+
buildMcpServer: () => buildMcpServer,
|
|
195
|
+
startMcpServer: () => startMcpServer
|
|
196
|
+
});
|
|
197
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
198
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
199
|
+
import { z } from "zod";
|
|
200
|
+
function buildMcpServer(client) {
|
|
201
|
+
const c = client ?? new HTMLShipClient();
|
|
202
|
+
const server = new McpServer({ name: "htmlship", version: VERSION });
|
|
203
|
+
server.registerTool(
|
|
204
|
+
"publish_html",
|
|
205
|
+
{
|
|
206
|
+
description: "Publish an HTML document and get a public URL. The owner_key returned is the only credential to update or delete this paste later \u2014 save it.",
|
|
207
|
+
inputSchema: {
|
|
208
|
+
html: z.string().describe("The HTML body to publish."),
|
|
209
|
+
title: z.string().optional().describe("Optional human-readable title."),
|
|
210
|
+
expires_in_seconds: z.number().int().positive().optional().describe("Optional TTL in seconds.")
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
async ({ html, title, expires_in_seconds }) => {
|
|
214
|
+
try {
|
|
215
|
+
const paste = await c.publish(html, {
|
|
216
|
+
title: title ?? null,
|
|
217
|
+
expiresIn: expires_in_seconds ?? null
|
|
218
|
+
});
|
|
219
|
+
return {
|
|
220
|
+
content: [
|
|
221
|
+
{
|
|
222
|
+
type: "text",
|
|
223
|
+
text: JSON.stringify(
|
|
224
|
+
{
|
|
225
|
+
url: paste.url,
|
|
226
|
+
slug: paste.slug,
|
|
227
|
+
owner_key: paste.owner_key,
|
|
228
|
+
expires_at: paste.expires_at
|
|
229
|
+
},
|
|
230
|
+
null,
|
|
231
|
+
2
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
]
|
|
235
|
+
};
|
|
236
|
+
} catch (err) {
|
|
237
|
+
return errorResult("publish_html", err);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
);
|
|
241
|
+
server.registerTool(
|
|
242
|
+
"fetch_html",
|
|
243
|
+
{
|
|
244
|
+
description: "Fetch a paste's metadata. To view the rendered HTML, open the `url` in a browser \u2014 the content is served from a sandboxed subdomain.",
|
|
245
|
+
inputSchema: {
|
|
246
|
+
slug: z.string().describe("The paste's short identifier.")
|
|
247
|
+
}
|
|
248
|
+
},
|
|
249
|
+
async ({ slug }) => {
|
|
250
|
+
try {
|
|
251
|
+
const paste = await c.get(slug);
|
|
252
|
+
return {
|
|
253
|
+
content: [{ type: "text", text: JSON.stringify(paste, null, 2) }]
|
|
254
|
+
};
|
|
255
|
+
} catch (err) {
|
|
256
|
+
if (err instanceof NotFoundError) {
|
|
257
|
+
return errorResult("fetch_html", new HTMLShipError(`paste '${slug}' not found`));
|
|
258
|
+
}
|
|
259
|
+
return errorResult("fetch_html", err);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
);
|
|
263
|
+
server.registerTool(
|
|
264
|
+
"update_html",
|
|
265
|
+
{
|
|
266
|
+
description: "Replace the HTML for an existing paste. Requires the original owner_key.",
|
|
267
|
+
inputSchema: {
|
|
268
|
+
slug: z.string().describe("The paste's short identifier."),
|
|
269
|
+
html: z.string().describe("The new HTML body."),
|
|
270
|
+
owner_key: z.string().describe("The owner key returned at publish time."),
|
|
271
|
+
title: z.string().optional().describe("Optional new title.")
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
async ({ slug, html, owner_key, title }) => {
|
|
275
|
+
try {
|
|
276
|
+
const paste = await c.update(slug, html, owner_key, { title: title ?? null });
|
|
277
|
+
return {
|
|
278
|
+
content: [
|
|
279
|
+
{
|
|
280
|
+
type: "text",
|
|
281
|
+
text: JSON.stringify(
|
|
282
|
+
{ url: paste.url, slug: paste.slug, updated_at: paste.updated_at },
|
|
283
|
+
null,
|
|
284
|
+
2
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
]
|
|
288
|
+
};
|
|
289
|
+
} catch (err) {
|
|
290
|
+
if (err instanceof AuthError) {
|
|
291
|
+
return errorResult("update_html", new HTMLShipError(`invalid owner_key for '${slug}'`));
|
|
292
|
+
}
|
|
293
|
+
return errorResult("update_html", err);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
);
|
|
297
|
+
return server;
|
|
298
|
+
}
|
|
299
|
+
function errorResult(tool, err) {
|
|
300
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
301
|
+
return {
|
|
302
|
+
isError: true,
|
|
303
|
+
content: [{ type: "text", text: `htmlship ${tool} failed: ${message}` }]
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
async function startMcpServer() {
|
|
307
|
+
const server = buildMcpServer();
|
|
308
|
+
const transport = new StdioServerTransport();
|
|
309
|
+
await server.connect(transport);
|
|
310
|
+
}
|
|
311
|
+
var init_server = __esm({
|
|
312
|
+
"src/mcp/server.ts"() {
|
|
313
|
+
"use strict";
|
|
314
|
+
init_client();
|
|
315
|
+
init_errors();
|
|
316
|
+
init_version();
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// src/cli.ts
|
|
321
|
+
import { Command } from "commander";
|
|
322
|
+
|
|
323
|
+
// src/commands/delete.ts
|
|
324
|
+
init_client();
|
|
325
|
+
init_errors();
|
|
326
|
+
import { createInterface } from "readline/promises";
|
|
327
|
+
|
|
328
|
+
// src/keystore.ts
|
|
329
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "fs";
|
|
330
|
+
import { homedir } from "os";
|
|
331
|
+
import { join } from "path";
|
|
332
|
+
function createKeyStore(overrideDir) {
|
|
333
|
+
const dir = overrideDir ?? process.env["HTMLSHIP_KEYS_DIR"] ?? join(homedir(), ".htmlship");
|
|
334
|
+
const file = join(dir, "keys.json");
|
|
335
|
+
function load() {
|
|
336
|
+
if (!existsSync(file)) return {};
|
|
337
|
+
try {
|
|
338
|
+
const text = readFileSync(file, "utf8");
|
|
339
|
+
const parsed = JSON.parse(text);
|
|
340
|
+
if (parsed && typeof parsed === "object") return parsed;
|
|
341
|
+
return {};
|
|
342
|
+
} catch {
|
|
343
|
+
return {};
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
function save(data) {
|
|
347
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
348
|
+
const sorted = {};
|
|
349
|
+
for (const key of Object.keys(data).sort()) {
|
|
350
|
+
sorted[key] = data[key];
|
|
351
|
+
}
|
|
352
|
+
writeFileSync(file, JSON.stringify(sorted, null, 2), "utf8");
|
|
353
|
+
try {
|
|
354
|
+
chmodSync(file, 384);
|
|
355
|
+
} catch {
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return {
|
|
359
|
+
dir,
|
|
360
|
+
file,
|
|
361
|
+
load,
|
|
362
|
+
save,
|
|
363
|
+
remember(slug, entry) {
|
|
364
|
+
const data = load();
|
|
365
|
+
data[slug] = {
|
|
366
|
+
owner_key: entry.owner_key,
|
|
367
|
+
url: entry.url,
|
|
368
|
+
title: entry.title,
|
|
369
|
+
saved_at: entry.saved_at ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
370
|
+
};
|
|
371
|
+
save(data);
|
|
372
|
+
},
|
|
373
|
+
forget(slug) {
|
|
374
|
+
const data = load();
|
|
375
|
+
if (slug in data) {
|
|
376
|
+
delete data[slug];
|
|
377
|
+
save(data);
|
|
378
|
+
}
|
|
379
|
+
},
|
|
380
|
+
lookupOwnerKey(slug) {
|
|
381
|
+
const entry = load()[slug];
|
|
382
|
+
return entry ? entry.owner_key : null;
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// src/commands/delete.ts
|
|
388
|
+
function registerDelete(program) {
|
|
389
|
+
program.command("delete").description("Soft-delete a paste.").argument("<slug>", "Paste slug").option("--owner-key <key>", "Owner key (defaults to local store)").option("-y, --yes", "Skip confirmation").action(async function(slug, opts) {
|
|
390
|
+
const keys = createKeyStore();
|
|
391
|
+
const ownerKey = opts.ownerKey ?? keys.lookupOwnerKey(slug);
|
|
392
|
+
if (!ownerKey) {
|
|
393
|
+
throw new HTMLShipError(
|
|
394
|
+
`no owner key for '${slug}' in ${keys.file}; pass --owner-key explicitly`
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
if (!opts.yes) {
|
|
398
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
399
|
+
const answer = await rl.question(`Delete paste '${slug}'? [y/N] `);
|
|
400
|
+
rl.close();
|
|
401
|
+
if (!/^y(es)?$/i.test(answer.trim())) {
|
|
402
|
+
process.stderr.write("Aborted.\n");
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
const apiUrl = this.parent?.opts()?.apiUrl;
|
|
407
|
+
const client = new HTMLShipClient({ baseUrl: apiUrl });
|
|
408
|
+
await client.delete(slug, ownerKey);
|
|
409
|
+
keys.forget(slug);
|
|
410
|
+
process.stdout.write(`deleted ${slug}
|
|
411
|
+
`);
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// src/commands/get.ts
|
|
416
|
+
init_client();
|
|
417
|
+
init_errors();
|
|
418
|
+
function registerGet(program) {
|
|
419
|
+
program.command("get").description("Show metadata for a paste.").argument("<slug>", "Paste slug").action(async function(slug) {
|
|
420
|
+
const apiUrl = this.parent?.opts()?.apiUrl;
|
|
421
|
+
const client = new HTMLShipClient({ baseUrl: apiUrl });
|
|
422
|
+
try {
|
|
423
|
+
const paste = await client.get(slug);
|
|
424
|
+
process.stdout.write(`${JSON.stringify(paste, null, 2)}
|
|
425
|
+
`);
|
|
426
|
+
} catch (err) {
|
|
427
|
+
if (err instanceof NotFoundError) {
|
|
428
|
+
throw new HTMLShipError(`paste '${slug}' not found`);
|
|
429
|
+
}
|
|
430
|
+
throw err;
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// src/commands/list-mine.ts
|
|
436
|
+
init_client();
|
|
437
|
+
init_errors();
|
|
438
|
+
function registerListMine(program) {
|
|
439
|
+
program.command("list-mine").description("List pastes whose owner keys are saved locally.").option("--limit <n>", "max entries to show", "20").action(async function(opts) {
|
|
440
|
+
const keys = createKeyStore();
|
|
441
|
+
const data = keys.load();
|
|
442
|
+
const entries = Object.entries(data);
|
|
443
|
+
if (entries.length === 0) {
|
|
444
|
+
process.stdout.write(`No saved pastes in ${keys.file}.
|
|
445
|
+
`);
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
const limit = Number.parseInt(opts.limit ?? "20", 10);
|
|
449
|
+
const sorted = entries.sort((a, b) => (b[1].saved_at ?? "").localeCompare(a[1].saved_at ?? "")).slice(0, Number.isFinite(limit) ? limit : 20);
|
|
450
|
+
const apiUrl = this.parent?.opts()?.apiUrl;
|
|
451
|
+
const client = new HTMLShipClient({ baseUrl: apiUrl });
|
|
452
|
+
const rows = await Promise.all(
|
|
453
|
+
sorted.map(async ([slug, info]) => {
|
|
454
|
+
try {
|
|
455
|
+
const paste = await client.get(slug);
|
|
456
|
+
return {
|
|
457
|
+
slug,
|
|
458
|
+
url: info.url,
|
|
459
|
+
title: paste.title ?? info.title ?? "",
|
|
460
|
+
size: `${paste.size_bytes}B`,
|
|
461
|
+
views: String(paste.view_count),
|
|
462
|
+
status: "ok"
|
|
463
|
+
};
|
|
464
|
+
} catch (err) {
|
|
465
|
+
return {
|
|
466
|
+
slug,
|
|
467
|
+
url: info.url,
|
|
468
|
+
title: info.title ?? "",
|
|
469
|
+
size: "\u2014",
|
|
470
|
+
views: "\u2014",
|
|
471
|
+
status: err instanceof NotFoundError ? "gone" : "err"
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
})
|
|
475
|
+
);
|
|
476
|
+
const width = Math.max(...rows.map((r) => r.slug.length), 8);
|
|
477
|
+
for (const r of rows) {
|
|
478
|
+
const slug = r.slug.padEnd(width);
|
|
479
|
+
const status = r.status.padEnd(5);
|
|
480
|
+
const views = r.views.padStart(5);
|
|
481
|
+
const size = r.size.padStart(8);
|
|
482
|
+
process.stdout.write(`${slug} ${status} ${views} views ${size} ${r.title}
|
|
483
|
+
`);
|
|
484
|
+
process.stdout.write(` ${r.url}
|
|
485
|
+
`);
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// src/commands/publish.ts
|
|
491
|
+
init_client();
|
|
492
|
+
init_errors();
|
|
493
|
+
|
|
494
|
+
// src/io.ts
|
|
495
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
496
|
+
function readHtmlFromSource(source, fileFlag) {
|
|
497
|
+
if (fileFlag) {
|
|
498
|
+
return readFileSync2(fileFlag, "utf8");
|
|
499
|
+
}
|
|
500
|
+
if (source === "-" || source === void 0) {
|
|
501
|
+
if (source === void 0 && process.stdin.isTTY) {
|
|
502
|
+
throw new CliUsageError("Provide a file path or pipe HTML on stdin.");
|
|
503
|
+
}
|
|
504
|
+
return readStdinSync();
|
|
505
|
+
}
|
|
506
|
+
return readFileSync2(source, "utf8");
|
|
507
|
+
}
|
|
508
|
+
var CliUsageError = class extends Error {
|
|
509
|
+
constructor(message) {
|
|
510
|
+
super(message);
|
|
511
|
+
this.name = "CliUsageError";
|
|
512
|
+
}
|
|
513
|
+
};
|
|
514
|
+
function readStdinSync() {
|
|
515
|
+
try {
|
|
516
|
+
return readFileSync2(0, "utf8");
|
|
517
|
+
} catch (err) {
|
|
518
|
+
throw new CliUsageError(`failed to read stdin: ${err.message}`);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
async function tryClipboardCopy(text) {
|
|
522
|
+
try {
|
|
523
|
+
const mod = await import("clipboardy");
|
|
524
|
+
await mod.default.write(text);
|
|
525
|
+
return true;
|
|
526
|
+
} catch {
|
|
527
|
+
return false;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// src/commands/publish.ts
|
|
532
|
+
function registerPublish(program) {
|
|
533
|
+
program.command("publish").description("Publish an HTML document and get a URL.").argument("[source]", "file path, '-' for stdin (default: stdin if piped)").option("-f, --file <path>", "HTML file path").option("--title <title>", "Optional title").option("--password <password>", "Password-protect the paste").option("--expires-in <seconds>", "Seconds until expiry").option("--no-clipboard", "Don't copy URL to clipboard").option("-q, --quiet", "Print only the URL").action(async function(source, opts) {
|
|
534
|
+
const html = readHtmlFromSource(source, opts.file);
|
|
535
|
+
const apiUrl = this.parent?.opts()?.apiUrl;
|
|
536
|
+
const client = new HTMLShipClient({ baseUrl: apiUrl });
|
|
537
|
+
let paste;
|
|
538
|
+
try {
|
|
539
|
+
paste = await client.publish(html, {
|
|
540
|
+
title: opts.title ?? null,
|
|
541
|
+
password: opts.password ?? null,
|
|
542
|
+
expiresIn: opts.expiresIn ? Number.parseInt(opts.expiresIn, 10) : null
|
|
543
|
+
});
|
|
544
|
+
} catch (err) {
|
|
545
|
+
throw new HTMLShipError(`publish failed: ${err.message}`);
|
|
546
|
+
}
|
|
547
|
+
const keys = createKeyStore();
|
|
548
|
+
keys.remember(paste.slug, {
|
|
549
|
+
owner_key: paste.owner_key,
|
|
550
|
+
url: paste.url,
|
|
551
|
+
title: opts.title ?? null
|
|
552
|
+
});
|
|
553
|
+
if (opts.quiet) {
|
|
554
|
+
process.stdout.write(`${paste.url}
|
|
555
|
+
`);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
process.stdout.write(`${paste.url}
|
|
559
|
+
`);
|
|
560
|
+
process.stderr.write(`slug: ${paste.slug}
|
|
561
|
+
`);
|
|
562
|
+
process.stderr.write(`owner_key: ${paste.owner_key} (saved to ${keys.file})
|
|
563
|
+
`);
|
|
564
|
+
if (paste.expires_at) {
|
|
565
|
+
process.stderr.write(`expires: ${paste.expires_at}
|
|
566
|
+
`);
|
|
567
|
+
}
|
|
568
|
+
if (opts.noClipboard !== true) {
|
|
569
|
+
const copied = await tryClipboardCopy(paste.url);
|
|
570
|
+
if (copied) process.stderr.write("(URL copied to clipboard)\n");
|
|
571
|
+
}
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// src/commands/update.ts
|
|
576
|
+
init_client();
|
|
577
|
+
init_errors();
|
|
578
|
+
function registerUpdate(program) {
|
|
579
|
+
program.command("update").description("Replace HTML for an existing paste.").argument("<slug>", "Paste slug").argument("[source]", "file path, '-' for stdin").option("-f, --file <path>", "HTML file path").option("--title <title>", "Optional new title").option("--owner-key <key>", "Owner key (defaults to local store)").action(async function(slug, source, opts) {
|
|
580
|
+
const html = readHtmlFromSource(source, opts.file);
|
|
581
|
+
const keys = createKeyStore();
|
|
582
|
+
const ownerKey = opts.ownerKey ?? keys.lookupOwnerKey(slug);
|
|
583
|
+
if (!ownerKey) {
|
|
584
|
+
throw new HTMLShipError(
|
|
585
|
+
`no owner key for '${slug}' in ${keys.file}; pass --owner-key explicitly`
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
const apiUrl = this.parent?.opts()?.apiUrl;
|
|
589
|
+
const client = new HTMLShipClient({ baseUrl: apiUrl });
|
|
590
|
+
const paste = await client.update(slug, html, ownerKey, { title: opts.title ?? null });
|
|
591
|
+
process.stdout.write(`${paste.url}
|
|
592
|
+
`);
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// src/cli.ts
|
|
597
|
+
init_errors();
|
|
598
|
+
init_version();
|
|
599
|
+
function buildProgram() {
|
|
600
|
+
const program = new Command();
|
|
601
|
+
program.name("htmlship").description("HTMLShip \u2014 host and share HTML in one line.").version(VERSION).option(
|
|
602
|
+
"--api-url <url>",
|
|
603
|
+
"API base URL (default: https://api.htmlship.com or $HTMLSHIP_API_URL)"
|
|
604
|
+
);
|
|
605
|
+
registerPublish(program);
|
|
606
|
+
registerGet(program);
|
|
607
|
+
registerUpdate(program);
|
|
608
|
+
registerDelete(program);
|
|
609
|
+
registerListMine(program);
|
|
610
|
+
program.command("mcp").description("Start an MCP stdio server (for Claude Desktop, Cursor, etc.).").action(async () => {
|
|
611
|
+
const { startMcpServer: startMcpServer2 } = await Promise.resolve().then(() => (init_server(), server_exports));
|
|
612
|
+
await startMcpServer2();
|
|
613
|
+
});
|
|
614
|
+
return program;
|
|
615
|
+
}
|
|
616
|
+
async function runCli(argv) {
|
|
617
|
+
const program = buildProgram();
|
|
618
|
+
try {
|
|
619
|
+
await program.parseAsync(argv);
|
|
620
|
+
} catch (err) {
|
|
621
|
+
if (err instanceof HTMLShipError || err instanceof CliUsageError) {
|
|
622
|
+
process.stderr.write(`error: ${err.message}
|
|
623
|
+
`);
|
|
624
|
+
process.exit(1);
|
|
625
|
+
}
|
|
626
|
+
throw err;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// src/bin.ts
|
|
631
|
+
runCli(process.argv).catch((err) => {
|
|
632
|
+
process.stderr.write(`error: ${err.message ?? String(err)}
|
|
633
|
+
`);
|
|
634
|
+
process.exit(1);
|
|
635
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "htmlship",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Host and share HTML pages from LLMs and coding agents in one line. CLI + MCP server.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"html",
|
|
7
|
+
"paste",
|
|
8
|
+
"llm",
|
|
9
|
+
"agent",
|
|
10
|
+
"mcp",
|
|
11
|
+
"hosting",
|
|
12
|
+
"cli",
|
|
13
|
+
"claude",
|
|
14
|
+
"cursor"
|
|
15
|
+
],
|
|
16
|
+
"homepage": "https://htmlship.com",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "https://github.com/htmlship/htmlship.git",
|
|
20
|
+
"directory": "npm"
|
|
21
|
+
},
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"author": "HTMLShip",
|
|
24
|
+
"type": "module",
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=18"
|
|
27
|
+
},
|
|
28
|
+
"bin": {
|
|
29
|
+
"htmlship": "./dist/cli.js"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"README.md",
|
|
34
|
+
"LICENSE"
|
|
35
|
+
],
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsup",
|
|
38
|
+
"dev": "tsup --watch",
|
|
39
|
+
"test": "vitest run",
|
|
40
|
+
"test:watch": "vitest",
|
|
41
|
+
"typecheck": "tsc --noEmit",
|
|
42
|
+
"prepublishOnly": "npm run build && npm test"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
46
|
+
"clipboardy": "^4.0.0",
|
|
47
|
+
"commander": "^12.1.0",
|
|
48
|
+
"zod": "^3.23.8"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@types/node": "^20.14.0",
|
|
52
|
+
"tsup": "^8.3.0",
|
|
53
|
+
"typescript": "^5.6.0",
|
|
54
|
+
"vitest": "^2.1.0"
|
|
55
|
+
}
|
|
56
|
+
}
|