htmlhost-cli 2.4.0 → 2.6.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 CHANGED
@@ -131,6 +131,17 @@ push changes and `htmlhost pull` to sync remote edits.
131
131
 
132
132
  Use `--force` to overwrite an existing non-empty directory.
133
133
 
134
+ ### `htmlhost agents [dir] [options]`
135
+ Teach your AI coding tool (Claude Code, Codex, Cursor, Copilot…) how htmlhost works: static hosting rules, this CLI, and how to build forms that deliver to your inbox. Writes a managed block into `AGENTS.md` (your own content is kept) and adds an `@AGENTS.md` import to `CLAUDE.md`. `clone` adds it automatically, and `deploy` keeps it current. Agent files are never deployed.
136
+
137
+ ```bash
138
+ htmlhost agents # add or refresh
139
+ htmlhost agents --print # print the guide instead
140
+ htmlhost agents --no-claude # don't touch CLAUDE.md
141
+ ```
142
+
143
+ The same guide is at https://htmlhost.co/agents.md.
144
+
134
145
  ### `htmlhost list`
135
146
  List all your sites with titles, sizes, TTL, and expiry. Multi-page sites are marked with 📄.
136
147
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "htmlhost-cli",
3
- "version": "2.4.0",
3
+ "version": "2.6.0",
4
4
  "description": "Deploy HTML files from the terminal — htmlhost.co CLI",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.mjs CHANGED
@@ -5,7 +5,7 @@ import { bold, dim, cyan, err } from "./ui.mjs";
5
5
  import { ApiError } from "./api.mjs";
6
6
  import { startUpdateCheck, applyUpdate } from "./update.mjs";
7
7
 
8
- const VERSION = "2.4.0";
8
+ const VERSION = "2.6.0";
9
9
 
10
10
  const HELP = `
11
11
  ${bold("htmlhost")} ${dim(`v${VERSION}`)} — deploy HTML from the terminal
@@ -15,6 +15,8 @@ const HELP = `
15
15
  ${cyan("htmlhost upload")} <file|dir...> [opts] Upload media file or directory
16
16
  ${cyan("htmlhost pull")} [slug] [opts] Pull remote changes to local
17
17
  ${cyan("htmlhost clone")} <slug> [dir] Clone a site to a local directory
18
+ ${cyan("htmlhost agents")} [dir] Teach your AI coding tool htmlhost (AGENTS.md)
19
+ ${cyan("htmlhost recipients")} [slug] [cmd] Manage form notification recipients
18
20
  ${cyan("htmlhost list")} List your sites
19
21
  ${cyan("htmlhost delete")} <slug> Delete a site
20
22
  ${cyan("htmlhost login")} Authenticate with an API token
@@ -39,6 +41,17 @@ const HELP = `
39
41
  --force, -f Pull without confirmation prompt
40
42
  --dry-run Show what would change without writing
41
43
 
44
+ ${bold("Agents options:")}
45
+ --print Print the guide instead of writing files
46
+ --no-claude Don't add the @AGENTS.md import to CLAUDE.md
47
+
48
+ ${bold("Recipients subcommands:")}
49
+ ${cyan("recipients")} [slug] List recipients
50
+ ${cyan("recipients add")} [slug] <email> Invite a new recipient
51
+ ${cyan("recipients remove")} [slug] <ref> Remove a recipient (email or id)
52
+ ${cyan("recipients resend")} [slug] <ref> Resend a pending invitation
53
+ --force, -f Skip confirmation on remove
54
+
42
55
  ${bold("Delete options:")}
43
56
  --force, -f Skip confirmation prompt
44
57
 
@@ -128,6 +141,16 @@ export async function run(argv) {
128
141
  await clone(args);
129
142
  break;
130
143
  }
144
+ case "agents": {
145
+ const { agents } = await import("./commands/agents.mjs");
146
+ await agents(args);
147
+ break;
148
+ }
149
+ case "recipients": {
150
+ const { recipients } = await import("./commands/recipients.mjs");
151
+ await recipients(args, { json: jsonMode });
152
+ break;
153
+ }
131
154
  case "list":
132
155
  case "ls": {
133
156
  const { list } = await import("./commands/list.mjs");
@@ -0,0 +1,95 @@
1
+ /**
2
+ * htmlhost agents [dir] [--print] [--no-claude]
3
+ *
4
+ * Write htmlhost's instructions for AI coding agents (hosting rules, CLI, forms)
5
+ * into the project's AGENTS.md, so tools like Codex, Cursor, Copilot and Claude
6
+ * Code build features that work on htmlhost. The guide is fetched from
7
+ * https://htmlhost.co/agents.md so it's always current.
8
+ *
9
+ * Only a marked block is managed: anything else in AGENTS.md is left alone,
10
+ * and running the command again replaces just that block. Claude Code reads
11
+ * CLAUDE.md rather than AGENTS.md, so an `@AGENTS.md` import is added there too.
12
+ */
13
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
14
+ import { join, resolve } from "node:path";
15
+ import { getApi } from "../config.mjs";
16
+ import { ok, err, dim, cyan } from "../ui.mjs";
17
+
18
+ const START = "<!-- htmlhost:agents:start — managed by `htmlhost agents`; edits inside this block are replaced -->";
19
+ const END = "<!-- htmlhost:agents:end -->";
20
+ const BLOCK_RE = /<!-- htmlhost:agents:start[^>]*-->[\s\S]*?<!-- htmlhost:agents:end -->/;
21
+
22
+ /** Fetch the current guide. Throws on network or HTTP failure. */
23
+ export async function fetchAgentGuide(timeoutMs = 10000) {
24
+ const res = await fetch(`${getApi()}/agents.md`, { signal: AbortSignal.timeout(timeoutMs) });
25
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
26
+ return (await res.text()).trim();
27
+ }
28
+
29
+ /** Whether a project already carries the managed block. */
30
+ export function hasAgentGuide(dir) {
31
+ const path = join(dir, "AGENTS.md");
32
+ return existsSync(path) && BLOCK_RE.test(readFileSync(path, "utf8"));
33
+ }
34
+
35
+ /**
36
+ * Write (or refresh) the guide in `dir`. Returns what changed, for reporting.
37
+ * `claude: false` leaves CLAUDE.md untouched.
38
+ */
39
+ export function writeAgentGuide(dir, guide, { claude = true } = {}) {
40
+ const changed = [];
41
+ const block = `${START}\n${guide}\n${END}`;
42
+
43
+ const agentsPath = join(dir, "AGENTS.md");
44
+ const before = existsSync(agentsPath) ? readFileSync(agentsPath, "utf8") : null;
45
+ const after = before === null
46
+ ? `${block}\n`
47
+ : BLOCK_RE.test(before)
48
+ ? before.replace(BLOCK_RE, () => block)
49
+ : `${before.replace(/\s*$/, "")}\n\n${block}\n`;
50
+ if (after !== before) {
51
+ writeFileSync(agentsPath, after, "utf8");
52
+ changed.push(before === null ? "created AGENTS.md" : "updated AGENTS.md");
53
+ }
54
+
55
+ if (claude) {
56
+ const claudePath = join(dir, "CLAUDE.md");
57
+ const current = existsSync(claudePath) ? readFileSync(claudePath, "utf8") : null;
58
+ if (current === null) {
59
+ writeFileSync(claudePath, "@AGENTS.md\n", "utf8");
60
+ changed.push("created CLAUDE.md (imports AGENTS.md)");
61
+ } else if (!/@AGENTS\.md\b/.test(current)) {
62
+ writeFileSync(claudePath, `${current.replace(/\s*$/, "")}\n\n@AGENTS.md\n`, "utf8");
63
+ changed.push("added @AGENTS.md to CLAUDE.md");
64
+ }
65
+ }
66
+ return changed;
67
+ }
68
+
69
+ export async function agents(args) {
70
+ const positionals = args.filter((a) => !a.startsWith("-"));
71
+ const dir = resolve(positionals[0] || ".");
72
+ const print = args.includes("--print");
73
+
74
+ let guide;
75
+ try {
76
+ guide = await fetchAgentGuide();
77
+ } catch (e) {
78
+ err(`Couldn't download the agent guide from ${getApi()}/agents.md (${e.message}).`);
79
+ process.exit(1);
80
+ }
81
+
82
+ if (print) {
83
+ console.log(guide);
84
+ return;
85
+ }
86
+
87
+ const changed = writeAgentGuide(dir, guide, { claude: !args.includes("--no-claude") });
88
+ if (changed.length === 0) {
89
+ ok("AI agent instructions are already up to date.");
90
+ } else {
91
+ ok(`AI agent instructions ${changed.join(", ")}.`);
92
+ }
93
+ console.log(` ${dim("Your AI coding tool now knows how htmlhost hosting and forms work.")}`);
94
+ console.log(` ${dim("Refresh any time with")} ${cyan("htmlhost agents")}${dim(". These files are never deployed.")}`);
95
+ }
@@ -9,6 +9,7 @@
9
9
  import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
10
10
  import { join, dirname, resolve } from "node:path";
11
11
  import { get, downloadBuffer } from "../api.mjs";
12
+ import { fetchAgentGuide, writeAgentGuide } from "./agents.mjs";
12
13
  import {
13
14
  ok,
14
15
  err,
@@ -164,6 +165,12 @@ export async function clone(args) {
164
165
  const linkPath = join(destDir, LINK_FILE);
165
166
  writeFileSync(linkPath, JSON.stringify(linkData, null, 2) + "\n", "utf8");
166
167
 
168
+ // Teach the user's AI coding tool how htmlhost works. Best-effort: a
169
+ // failed download mustn't fail the clone.
170
+ const agentsWritten = await fetchAgentGuide(5000)
171
+ .then((guide) => writeAgentGuide(destDir, guide).length > 0)
172
+ .catch(() => false);
173
+
167
174
  // --- Done ---
168
175
  console.log("");
169
176
  console.log(` ${green("━".repeat(40))}`);
@@ -174,5 +181,6 @@ export async function clone(args) {
174
181
  console.log("");
175
182
  console.log(` ${dim("Deploy changes:")} ${cyan("htmlhost deploy")}`);
176
183
  console.log(` ${dim("Pull updates:")} ${cyan("htmlhost pull")}`);
184
+ if (agentsWritten) console.log(` ${dim("AI agent guide:")} ${cyan("AGENTS.md")} ${dim("(not deployed)")}`);
177
185
  console.log("");
178
186
  }
@@ -5,6 +5,7 @@ import { createHash } from "node:crypto";
5
5
  import { post } from "../api.mjs";
6
6
  import { ok, err, info, warn, cyan, dim, bold, yellow, green, formatBytes, mimeFromExt } from "../ui.mjs";
7
7
  import { checkRemoteChanges } from "./pull.mjs";
8
+ import { fetchAgentGuide, hasAgentGuide, writeAgentGuide } from "./agents.mjs";
8
9
 
9
10
  const LINK_FILE = ".htmlhost";
10
11
 
@@ -68,6 +69,15 @@ const DEFAULT_IGNORE = [
68
69
  "package-lock.json",
69
70
  "yarn.lock",
70
71
  "pnpm-lock.yaml",
72
+ // Instructions for AI coding tools — private to the project, never published.
73
+ "AGENTS.md",
74
+ "CLAUDE.md",
75
+ "GEMINI.md",
76
+ ".claude",
77
+ ".cursor",
78
+ ".cursorrules",
79
+ ".windsurfrules",
80
+ ".github",
71
81
  ];
72
82
 
73
83
  /**
@@ -342,6 +352,10 @@ async function deployDirectory(dirPath, { ttl, title, slug: explicitSlug, forceN
342
352
  }
343
353
  }
344
354
 
355
+ // Keep an existing AI agent guide current (downloaded alongside the deploy).
356
+ const hadAgentGuide = hasAgentGuide(dirPath);
357
+ const agentGuide = hadAgentGuide && !json ? fetchAgentGuide(5000).catch(() => null) : Promise.resolve(null);
358
+
345
359
  // Read existing link data
346
360
  const link = readLink(dirPath);
347
361
  let existingSlug = explicitSlug || (!forceNew && link?.multipage?.slug) || null;
@@ -557,6 +571,11 @@ async function deployDirectory(dirPath, { ttl, title, slug: explicitSlug, forceN
557
571
  }
558
572
  console.log("");
559
573
  console.log(` ${dim("Linked → .htmlhost")}`);
574
+ const guide = await agentGuide;
575
+ if (guide) writeAgentGuide(dirPath, guide, { claude: false });
576
+ else if (!hadAgentGuide) {
577
+ console.log(` ${dim("Building with AI? Run")} ${cyan("htmlhost agents")} ${dim("so your coding tool knows how htmlhost forms and hosting work.")}`);
578
+ }
560
579
  console.log("");
561
580
  } catch (e) {
562
581
  err(`Deploy failed: ${e.message}`);
@@ -0,0 +1,270 @@
1
+ import { get, post } from "../api.mjs";
2
+ import { ok, err, info, dim, cyan, bold, yellow, confirm } from "../ui.mjs";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+
6
+ const LINK_FILE = ".htmlhost";
7
+
8
+ /**
9
+ * Read the slug from .htmlhost in the given (or current) directory.
10
+ */
11
+ function readSlugFromLink(dir = process.cwd()) {
12
+ const linkPath = join(dir, LINK_FILE);
13
+ if (!existsSync(linkPath)) return null;
14
+ try {
15
+ const data = JSON.parse(readFileSync(linkPath, "utf8"));
16
+ return data.slug || null;
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Resolve the slug: explicit argument first, then .htmlhost fallback.
24
+ */
25
+ function resolveSlug(args) {
26
+ const positional = args.filter((a) => !a.startsWith("--"));
27
+ const explicit = positional[0];
28
+ if (explicit) return explicit;
29
+ const linked = readSlugFromLink();
30
+ if (linked) return linked;
31
+ return null;
32
+ }
33
+
34
+ /**
35
+ * Resolve an email-or-id argument to a recipient id by fetching the list.
36
+ * Returns { id, email } or null if not found.
37
+ */
38
+ async function resolveRecipient(slug, ref) {
39
+ // Already a UUID?
40
+ if (/^[0-9a-f-]{36}$/i.test(ref)) {
41
+ return { id: ref, email: null };
42
+ }
43
+ // Treat as email — look it up
44
+ const data = await get(`/api/sites/${encodeURIComponent(slug)}/recipients`);
45
+ const match = data.recipients?.find((r) => r.email === ref.toLowerCase());
46
+ if (!match) return null;
47
+ return { id: match.id, email: match.email };
48
+ }
49
+
50
+ function statusLabel(status) {
51
+ switch (status) {
52
+ case "confirmed": return cyan("confirmed");
53
+ case "pending": return yellow("pending");
54
+ case "unsubscribed": return dim("unsubscribed");
55
+ default: return dim(status);
56
+ }
57
+ }
58
+
59
+ function pad(str, width) {
60
+ const s = String(str);
61
+ const visible = s.replace(/\x1b\[[0-9;]*m/g, "");
62
+ return s + " ".repeat(Math.max(0, width - visible.length));
63
+ }
64
+
65
+ function relDate(iso) {
66
+ if (!iso) return "";
67
+ const d = new Date(iso);
68
+ return d.toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" });
69
+ }
70
+
71
+ /**
72
+ * htmlhost recipients [slug] [subcommand] [args] [--json] [--force]
73
+ */
74
+ export async function recipients(args, { json = false } = {}) {
75
+ // Parse subcommand: the first non-flag arg might be a slug or a subcommand
76
+ const positional = args.filter((a) => !a.startsWith("--"));
77
+ const force = args.includes("--force") || args.includes("-f");
78
+
79
+ // Detect subcommand pattern
80
+ const subcommands = ["add", "remove", "resend"];
81
+ let subcommand = null;
82
+ let slug = null;
83
+ let subArgs = [];
84
+
85
+ if (subcommands.includes(positional[0])) {
86
+ // `htmlhost recipients add [slug] <email>` — subcommand first, slug from .htmlhost or second arg
87
+ subcommand = positional[0];
88
+ // Remaining positional args: could be [slug, email] or just [email]
89
+ subArgs = positional.slice(1);
90
+ } else if (positional.length >= 2 && subcommands.includes(positional[1])) {
91
+ // `htmlhost recipients <slug> add <email>` — slug first, then subcommand
92
+ slug = positional[0];
93
+ subcommand = positional[1];
94
+ subArgs = positional.slice(2);
95
+ } else {
96
+ // `htmlhost recipients [slug]` — list mode
97
+ slug = resolveSlug(args);
98
+ }
99
+
100
+ // For subcommands, resolve slug if not already set
101
+ if (subcommand && !slug) {
102
+ // subArgs might start with a slug if there are enough args
103
+ if (subcommand === "add" && subArgs.length >= 2) {
104
+ // `recipients add <slug> <email>`
105
+ slug = subArgs[0];
106
+ subArgs = subArgs.slice(1);
107
+ } else if ((subcommand === "remove" || subcommand === "resend") && subArgs.length >= 2) {
108
+ slug = subArgs[0];
109
+ subArgs = subArgs.slice(1);
110
+ } else {
111
+ // Try .htmlhost
112
+ slug = readSlugFromLink();
113
+ }
114
+ }
115
+
116
+ if (!slug) {
117
+ err("No site specified. Pass a slug or run from a directory with a .htmlhost file.");
118
+ console.log(` ${dim("Usage:")} ${cyan("htmlhost recipients")} ${dim("[slug]")}`);
119
+ process.exit(1);
120
+ }
121
+
122
+ if (!subcommand) {
123
+ return listCmd(slug, json);
124
+ }
125
+
126
+ switch (subcommand) {
127
+ case "add": return addCmd(slug, subArgs, json);
128
+ case "remove": return removeCmd(slug, subArgs, json, force);
129
+ case "resend": return resendCmd(slug, subArgs, json);
130
+ default:
131
+ err(`Unknown subcommand: ${subcommand}`);
132
+ process.exit(1);
133
+ }
134
+ }
135
+
136
+ async function listCmd(slug, json) {
137
+ const data = await get(`/api/sites/${encodeURIComponent(slug)}/recipients`);
138
+
139
+ if (json) {
140
+ console.log(JSON.stringify(data));
141
+ return;
142
+ }
143
+
144
+ console.log("");
145
+ info(`Recipients for ${cyan(bold(slug + ".htmlhost.co"))}`);
146
+ console.log("");
147
+
148
+ // Owner row
149
+ const ownerStatus = data.notifyOwner ? cyan("receiving") : dim("off");
150
+ console.log(` ${pad(data.ownerEmail, 36)} ${pad(ownerStatus, 18)} ${dim("owner")}`);
151
+
152
+ if (!data.recipients || data.recipients.length === 0) {
153
+ console.log("");
154
+ console.log(` ${dim("No extra recipients.")}`);
155
+ console.log(` ${dim("Add one with:")} ${cyan(`htmlhost recipients add ${slug} name@example.com`)}`);
156
+ console.log("");
157
+ return;
158
+ }
159
+
160
+ console.log(` ${dim("─".repeat(60))}`);
161
+
162
+ for (const r of data.recipients) {
163
+ const status = statusLabel(r.status);
164
+ const forms = r.forms ? dim(`[${r.forms.join(", ")}]`) : "";
165
+ const invited = dim(relDate(r.invitedAt));
166
+ console.log(` ${pad(r.email, 36)} ${pad(status, 18)} ${invited} ${forms}`);
167
+ }
168
+
169
+ const active = data.recipients.filter((r) => r.status !== "unsubscribed").length;
170
+ console.log("");
171
+ console.log(` ${bold(String(active))}/${data.limit} recipient slots used ${dim(`(${data.isPro ? "pro" : "free"})`)}`);
172
+ console.log("");
173
+ }
174
+
175
+ async function addCmd(slug, subArgs, json) {
176
+ const email = subArgs[0];
177
+ if (!email) {
178
+ err("Usage: htmlhost recipients add [slug] <email>");
179
+ process.exit(1);
180
+ }
181
+
182
+ const res = await post(`/api/sites/${encodeURIComponent(slug)}/recipients`, {
183
+ action: "add",
184
+ email,
185
+ });
186
+
187
+ if (json) {
188
+ console.log(JSON.stringify(res));
189
+ return;
190
+ }
191
+
192
+ if (res.ok) {
193
+ ok(res.message || `Added ${email}.`);
194
+ } else {
195
+ err(res.error || "Failed to add recipient.");
196
+ process.exit(1);
197
+ }
198
+ }
199
+
200
+ async function removeCmd(slug, subArgs, json, force) {
201
+ const ref = subArgs[0];
202
+ if (!ref) {
203
+ err("Usage: htmlhost recipients remove [slug] <email|id>");
204
+ process.exit(1);
205
+ }
206
+
207
+ const resolved = await resolveRecipient(slug, ref);
208
+ if (!resolved) {
209
+ err(`Recipient not found: ${ref}`);
210
+ process.exit(1);
211
+ }
212
+
213
+ const label = resolved.email || ref;
214
+
215
+ if (!force) {
216
+ const yes = await confirm(` Remove ${cyan(bold(label))} from ${cyan(slug)}?`);
217
+ if (!yes) {
218
+ console.log(" Cancelled.");
219
+ return;
220
+ }
221
+ }
222
+
223
+ const res = await post(`/api/sites/${encodeURIComponent(slug)}/recipients`, {
224
+ action: "remove",
225
+ id: resolved.id,
226
+ });
227
+
228
+ if (json) {
229
+ console.log(JSON.stringify(res));
230
+ return;
231
+ }
232
+
233
+ if (res.ok) {
234
+ ok(`Removed ${label}.`);
235
+ } else {
236
+ err(res.error || "Failed to remove recipient.");
237
+ process.exit(1);
238
+ }
239
+ }
240
+
241
+ async function resendCmd(slug, subArgs, json) {
242
+ const ref = subArgs[0];
243
+ if (!ref) {
244
+ err("Usage: htmlhost recipients resend [slug] <email|id>");
245
+ process.exit(1);
246
+ }
247
+
248
+ const resolved = await resolveRecipient(slug, ref);
249
+ if (!resolved) {
250
+ err(`Recipient not found: ${ref}`);
251
+ process.exit(1);
252
+ }
253
+
254
+ const res = await post(`/api/sites/${encodeURIComponent(slug)}/recipients`, {
255
+ action: "resend",
256
+ id: resolved.id,
257
+ });
258
+
259
+ if (json) {
260
+ console.log(JSON.stringify(res));
261
+ return;
262
+ }
263
+
264
+ if (res.ok) {
265
+ ok(res.message || `Invitation re-sent.`);
266
+ } else {
267
+ err(res.error || "Failed to resend invitation.");
268
+ process.exit(1);
269
+ }
270
+ }