Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
"@octokit/rest": "^21.0.2",
"commander": "^12.1.0",
"execa": "^9.5.2",
"marked": "^18.0.4",
"smol-toml": "^1.3.1",
"undici": "^7.2.0"
},
Expand Down
107 changes: 107 additions & 0 deletions src/banner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// Help banner + Sentry-style top-level help formatter, used when the user
// runs `htmlbin` (no args) or `htmlbin --help`. The ASCII logo and ANSI
// colors are gated on TTY + NO_COLOR, so agents (whose stdout is piped)
// see a plain-text version. Sub-commands keep commander's default help.

const LOGO = [
"██╗ ██╗████████╗███╗ ███╗██╗ ██████╗ ██╗███╗ ██╗",
"██║ ██║╚══██╔══╝████╗ ████║██║ ██╔══██╗██║████╗ ██║",
"███████║ ██║ ██╔████╔██║██║ ██████╔╝██║██╔██╗ ██║",
"██╔══██║ ██║ ██║╚██╔╝██║██║ ██╔══██╗██║██║╚██╗██║",
"██║ ██║ ██║ ██║ ╚═╝ ██║███████╗██████╔╝██║██║ ╚████║",
"╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚══════╝╚═════╝ ╚═╝╚═╝ ╚═══╝",
] as const;

// One row per command group, with the subcommand verbs pipe-separated.
const COMMANDS: ReadonlyArray<readonly [string, string]> = [
["$ htmlbin publish <file>", "Publish an HTML file and print the URL"],
["$ htmlbin serve <path> | --status | --stop", "Run a local preview with live reload"],
["$ htmlbin list | url <slug> | delete <slug>", "Browse and manage published drops"],
["$ htmlbin patterns list | init | add <source>", "Install + manage local patterns"],
["$ htmlbin login", "Sign in to htmlbin.dev (cloud backend)"],
["$ htmlbin setup --to <backend>", "One-time backend setup (gh-pages, cloudflare)"],
];

const FLAGS: ReadonlyArray<readonly [string, string]> = [
["--to <backend>", "Backend: cloud | gh-pages | cloudflare"],
["--output <format>", "text | json (auto-flips to json in agent contexts)"],
["--debug", "Include upstream response bodies in errors"],
["--help", "Show help for a command"],
["--version", "Show version"],
];

const ENV_VARS: ReadonlyArray<readonly [string, string]> = [
["HTMLBIN_TOKEN", "Cloud API token (overrides ./.htmlbin/token)"],
["HTMLBIN_BACKEND", "Default backend (overrides .htmlbin/config)"],
["HTMLBIN_DEBUG", "Include upstream response bodies in error details"],
["GITHUB_TOKEN", "Auth for the gh-pages backend"],
["CLOUDFLARE_API_TOKEN", "Auth for the cloudflare backend"],
["CLAUDE_CODE / CURSOR_AGENT / ...", "When set, --output flips to json by default"],
["NO_COLOR", "Disable colored output (no-color.org)"],
];

const RED = "\x1b[31m";
const PINK = "\x1b[38;5;204m";
const DIM = "\x1b[2m";
const BOLD = "\x1b[1m";
const RESET = "\x1b[0m";

function useColor(): boolean {
if (process.env.NO_COLOR) return false;
if (process.env.FORCE_COLOR) return true;
return !!process.stdout.isTTY;
}

function paint(s: string, code: string): string {
return useColor() ? code + s + RESET : s;
}

function row(left: string, right: string, leftWidth: number): string {
return " " + left.padEnd(leftWidth) + paint(right, DIM);
}

export function formatRootHelp(version: string): string {
const lines: string[] = [];

// Banner — only when we have color. Otherwise the plain text help below
// stands alone (cleaner for agents and piped output).
if (useColor()) {
lines.push("");
for (const l of LOGO) lines.push(paint(l, RED));
lines.push("");
}

// Tagline — keep the red < > brackets that anchor the brand.
lines.push(
" The command-line interface for " +
paint("<", RED) + "htmlbin" + paint(">", RED) +
" — publish HTML, get a URL."
);
lines.push("");

// Commands
const cmdW = Math.max(...COMMANDS.map(([c]) => c.length)) + 4;
for (const [c, d] of COMMANDS) lines.push(row(c, d, cmdW));
lines.push("");

// Flags
lines.push(paint("Flags:", BOLD));
const flagW = Math.max(...FLAGS.map(([f]) => f.length)) + 4;
for (const [f, d] of FLAGS) lines.push(row(f, d, flagW));
lines.push("");

// Environment variables
lines.push(paint("Environment Variables:", BOLD));
const envW = Math.max(...ENV_VARS.map(([e]) => e.length)) + 4;
for (const [e, d] of ENV_VARS) lines.push(row(e, d, envW));
lines.push("");

// Try-this hint + Learn more footer
lines.push(" try: " + paint("htmlbin serve report.html", PINK));
lines.push("");
lines.push(" Learn more at " + paint("https://htmlbin.dev", RED) +
" " + paint("(v" + version + ")", DIM));
lines.push("");

return lines.join("\n");
}
97 changes: 97 additions & 0 deletions src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ import { listPatterns } from "./patterns/list.js";
import { initPatterns } from "./patterns/init.js";
import { ensureNoSilentSkip, installPattern } from "./patterns/install.js";
import { resolveSource } from "./patterns/sources.js";
import { startServe, statusServe, stopServe } from "./serve/index.js";
import { formatRootHelp } from "./banner.js";

const VERSION = "0.1.0";

Expand Down Expand Up @@ -194,6 +196,11 @@ async function run(): Promise<void> {
.name("htmlbin")
.description("Publish HTML, get a URL. Cloud by default; pluggable backends for org-internal hosting.")
.version(VERSION)
// Show help when invoked with no subcommand. Without this, commander
// exits with "missing required command" which isn't a useful landing.
.action(() => {
process.stdout.write(formatRootHelp(VERSION));
})
.addOption(
new Option("--to <backend>", "backend to use: cloud | gh-pages | cloudflare").choices([
"cloud",
Expand All @@ -212,6 +219,12 @@ async function run(): Promise<void> {
)
);

// Replace just the ROOT command's helpInformation. Assigning to
// configureHelp would propagate to subcommands; reassigning the method
// directly on `program` keeps subcommand `--help` using commander's
// default formatter (which is the right shape for per-command help).
program.helpInformation = () => formatRootHelp(VERSION);

// Sync OUTPUT_MODE + User-Agent before each command runs so die() and
// emit() see the resolved value, and outbound HTTP carries the detected
// agent. Precedence: explicit --output > agent env detection > "text".
Expand Down Expand Up @@ -383,9 +396,93 @@ async function run(): Promise<void> {
// --- patterns (sub-subcommands: list, init, add) ---
registerPatternsCommands(program);

// --- serve ---
registerServeCommand(program);

await program.parseAsync(process.argv);
}

// ---------------------------------------------------------------
// `htmlbin serve` — local preview server for HTML and Markdown files.
// Three modes on one command:
// serve <path> start (runs in foreground until SIGINT / idle)
// serve --status print state of any running server in this cwd
// serve --stop stop the running server in this cwd
// Agent-first: --output json emits structured events per line; in agent
// contexts the browser doesn't auto-open and idle timeout drops to 5 min.
// ---------------------------------------------------------------

interface ServeCmdOpts extends GlobalOpts {
port?: string;
host?: string;
open?: boolean;
reload?: boolean;
overlay?: boolean;
idleTimeout?: string;
status?: boolean;
stop?: boolean;
}

function registerServeCommand(program: Command): void {
program
.command("serve")
.description("Run a local preview server for an HTML or Markdown file (or a directory)")
.argument("[path]", "file or directory to serve (omit when using --status or --stop)")
.option("--port <n>", "port to listen on (default: ephemeral)")
.option("--host <addr>", "host to bind", "127.0.0.1")
.option("--no-open", "do not open the URL in a browser (default in agent contexts)")
.option("--no-reload", "disable live-reload on file change")
.option("--no-overlay", "disable the floating status pill on raw HTML pages")
.option("--idle-timeout <minutes>", "auto-shutdown after N idle minutes; 0 disables")
.option("--status", "print the state of the running server in this directory")
.option("--stop", "stop the running server in this directory")
.action(async (path: string | undefined, cmdOpts: ServeCmdOpts) => {
try {
const json = OUTPUT_MODE === "json";
if (cmdOpts.status) {
await statusServe(json);
return;
}
if (cmdOpts.stop) {
await stopServe(json);
return;
}
if (!path) {
throw new CliError(
"invalid_arg",
"Missing path. Pass a file or directory, or use --status / --stop."
);
}
const startArgs: Parameters<typeof startServe>[0] = {
path,
host: cmdOpts.host,
open: cmdOpts.open,
reload: cmdOpts.reload,
overlay: cmdOpts.overlay,
json,
agentContext: isAgentContext(),
};
if (cmdOpts.port !== undefined) {
const n = Number.parseInt(cmdOpts.port, 10);
if (!Number.isFinite(n) || n < 0 || n > 65535) {
throw new CliError("invalid_arg", `--port must be 0..65535 (got: ${cmdOpts.port})`);
}
startArgs.port = n;
}
if (cmdOpts.idleTimeout !== undefined) {
const n = Number.parseInt(cmdOpts.idleTimeout, 10);
if (!Number.isFinite(n) || n < 0) {
throw new CliError("invalid_arg", `--idle-timeout must be >= 0 (got: ${cmdOpts.idleTimeout})`);
}
startArgs.idleTimeout = n;
}
await startServe(startArgs);
} catch (e) {
die(e);
}
});
}

// ---------------------------------------------------------------
// `htmlbin patterns` — install + manage local patterns the skill at
// /.well-known/agent-skills/htmlbin/SKILL.md teaches agents to look for.
Expand Down
7 changes: 7 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ export type CliErrorCode =
| "cloudflare_access_not_enabled"
| "pr_required"
| "network_error"
| "serve_already_running"
| "serve_not_running"
| "serve_port_in_use"
| "unknown";

export class CliError extends Error {
Expand Down Expand Up @@ -119,7 +122,11 @@ export function exitCodeFor(code: CliErrorCode): number {
return 7;
case "network_error":
case "server_misconfigured":
case "serve_port_in_use":
return 8;
case "serve_already_running":
case "serve_not_running":
return 7;
default:
return 1;
}
Expand Down
Loading