Skip to content
Merged
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
23 changes: 23 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
"name": "cli-tools",
"description": "Profullstack's command-line tools as installable plugins — publish to the plain-HTML blog without getting a convention wrong.",
"owner": {
"name": "profullstack",
"url": "https://profullstack.com"
},
"plugins": [
{
"name": "blog",
"description": "Write, check and publish posts on the plain-HTML blog: next post number, smolweb-valid template, index listing and feed regeneration, with a lint that catches the mistakes that silently break RSS.",
"source": "./plugins/blog",
"category": "productivity",
"author": {
"name": "profullstack",
"url": "https://profullstack.com"
},
"homepage": "https://github.com/profullstack/cli-tools#blog",
"keywords": ["blog", "rss", "feed", "publishing", "smolweb"]
}
]
}
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ TypeScript, installed as executables on `PATH`.
| [`gh-prs-fix-all`](#gh-prs-fix-all) | Fix the open threatcrush-scan PRs that are broken because of us |
| [`tcfeed`](#tcfeed) | Find repositories worth scanning, scan them, print a shortlist |
| [`domainjson`](#domainjson) | whois-style, JSON-first name lookup |
| [`blog-post`](#blog-post) | Publish to the plain-HTML blog without breaking the feed |

## Requirements

Expand Down Expand Up @@ -170,6 +171,52 @@ goes through OpenRDAP. Either way `dig` adds records, hosts, reverse lookups and
per-nameserver AXFR attempts. Errors are JSON too — a tool whose output gets
parsed should not change shape when it fails.

### `blog-post`

Publishes to the plain-HTML blog at `~/public_html/blog`. That blog has no build
step and no CMS: writing a file *is* publishing. This exists because nothing
else catches a mistake before it is live.

```sh
blog-post new "A title" --description "The one-line feed summary"
blog-post new "A title" --description "..." --body draft.html
blog-post check # posts that will break the feed
blog-post list # every post with its date
blog-post feed # regenerate feed.xml
```

`new` picks the next `NNN-post.html`, renders the smolweb-valid template with
the AI-drafting acknowledgment, splices the entry into the hand-maintained
`index.html`, and runs the blog's own `build-feed.mjs`. Point it elsewhere with
`--dir` or `$BLOG_DIR`.

What it refuses to do:

- **Date a post in the future.** Such a post sorts above every real post, and
readers that filter future items drop it entirely — so the feed looks like it
stopped updating while the files on disk look perfect. This has happened:
three posts sat 7–10 hours ahead and did exactly that. `--allow-future` is
there if you genuinely mean to schedule.
- **Overwrite a post.** Two concurrent runs read the directory before either
writes, so both pick the same number; the write uses `wx` and the loser fails
loudly rather than silently replacing a post.
- **Skip the description.** It is the entire RSS summary.

`check` reports missing, unparseable and future dates, empty descriptions and a
missing `<h1>`, and exits non-zero, so it works as a pre-publish gate.

## As a moshcode plugin

This repo is also a plugin marketplace, exposing `blog-post` as slash commands:

```sh
moshcode plugin marketplace add profullstack/cli-tools
moshcode plugin install blog@cli-tools
```

That adds `/blog:post`, `/blog:check`, `/blog:list` and `/blog:feed`. See
[plugins/blog](plugins/blog/README.md).

## Aliases

Pit aliases live in `~/.moshcode/aliases.json`:
Expand All @@ -181,6 +228,7 @@ Pit aliases live in `~/.moshcode/aliases.json`:
/alias set fixprs "gh-prs-fix-all"
/alias set feed "tcfeed"
/alias set whoisj "domainjson"
/alias set blog "blog-post"

/alias # list
/alias get merge # show one
Expand Down
171 changes: 171 additions & 0 deletions bin/blog-post.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
#!/usr/bin/env -S npx --yes tsx
/**
* blog-post — write to the plain-HTML blog without getting a convention wrong.
*
* The blog has no build step: writing a file is publishing. Nothing else
* catches a post with no date (the feed generator skips it in silence) or one
* dated in the future (it pins above every real post, and readers that filter
* future items drop it, so the feed looks dead while the files look fine).
*/

import { existsSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { spawnSync } from 'node:child_process';
import { join } from 'node:path';

import { parseArgs, UsageError } from '../src/args.ts';
import { isMain } from '../src/is-main.ts';
import {
blogDir,
createPost,
DEFAULT_DIR,
isoSeconds,
lint,
readPosts,
} from '../src/blog.ts';

const USAGE = `Usage:
blog-post new <title> --description <text> [--body file.html] [--date ISO]
blog-post check
blog-post list
blog-post feed

Commands:
new Write the next post, list it in index.html, rebuild the feed
check Report posts that will break the feed (non-zero exit if any)
list Every post with its date
feed Regenerate feed.xml

Options:
--description TEXT Feed summary. Required by \`new\`.
--body FILE HTML fragment for the body (default: a stub)
--date ISO Publish date (default: now). Refuses the future.
--dir PATH Blog directory (default: $BLOG_DIR, else
${DEFAULT_DIR})
--allow-future Permit a future date. You almost never want this.
-h, --help show this help
`;

const SPEC = {
boolean: ['--allow-future', '-h', '--help'],
string: ['--description', '--body', '--date', '--dir'],
} as const;

/**
* Regenerate feed.xml by running the blog's own generator.
*
* Shelling out rather than reimplementing: build-feed.mjs lives beside the
* posts and is the single source of truth for the feed's shape.
*/
function rebuildFeed(dir: string): number {
const script = join(dir, 'build-feed.mjs');
if (!existsSync(script)) {
process.stderr.write(`no build-feed.mjs in ${dir} — feed not regenerated\n`);
return 1;
}
return spawnSync(process.execPath, [script], { cwd: dir, stdio: 'inherit' }).status ?? 1;
}

export async function run(argv: readonly string[]): Promise<number> {
let parsed;
try {
parsed = parseArgs(argv, SPEC);
} catch (error) {
if (error instanceof UsageError) {
process.stderr.write(`${error.message}\n\n${USAGE}`);
return 2;
}
throw error;
}

const { flags, values, positional } = parsed;

if (flags.has('-h') || flags.has('--help') || positional.length === 0) {
process.stdout.write(USAGE);
return positional.length === 0 && !flags.has('-h') && !flags.has('--help') ? 1 : 0;
}

const [command, ...rest] = positional;
const dir = blogDir(values.get('--dir'));

if (!existsSync(dir)) {
process.stderr.write(`blog directory not found: ${dir}\n`);
return 1;
}

switch (command) {
case 'new': {
const title = rest.join(' ').trim();
if (!title) {
process.stderr.write('new: give a title\n');
return 1;
}

const description = (values.get('--description') ?? '').trim();
if (!description) {
process.stderr.write('new: --description is required (it becomes the feed summary)\n');
return 1;
}

const raw = values.get('--date');
const when = raw ? new Date(raw) : new Date();
if (Number.isNaN(when.getTime())) {
process.stderr.write(`new: unparseable --date ${JSON.stringify(raw)}\n`);
return 1;
}
if (when.getTime() > Date.now() && !flags.has('--allow-future')) {
process.stderr.write(
`new: ${isoSeconds(when)} is in the future.\n` +
' A future-dated post sits above every real post, and readers that hide\n' +
' future items drop it, so the feed looks dead. Pass --allow-future only\n' +
' if you genuinely mean to schedule it.\n',
);
return 1;
}

const bodyFile = values.get('--body');
const body = bodyFile ? await readFile(bodyFile, 'utf8') : '';

const { file, path } = await createPost(dir, {
title,
description,
date: isoSeconds(when),
body,
});

process.stdout.write(`created ${file}\n ${path}\n listed in index.html\n`);
return rebuildFeed(dir);
}

case 'check': {
const problems = lint(await readPosts(dir));
if (problems.length === 0) {
process.stdout.write('all posts look publishable\n');
return 0;
}
for (const problem of problems) {
process.stderr.write(`${problem.file}: ${problem.problem}\n`);
}
return 1;
}

case 'list': {
for (const post of await readPosts(dir)) {
const title = (post.title ?? '(no h1)').replace(/&mdash;/g, '—').slice(0, 52);
process.stdout.write(`${post.file} ${(post.date ?? 'NO-DATE').padEnd(22)} ${title}\n`);
}
return 0;
}

case 'feed':
return rebuildFeed(dir);

default:
process.stderr.write(`unknown command: ${command}\n\n${USAGE}`);
return 1;
}
}

if (isMain(import.meta.url)) {
process.exitCode = await run(process.argv.slice(2));
}
13 changes: 13 additions & 0 deletions plugins/blog/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
"name": "blog",
"description": "Write, check and publish posts on the plain-HTML blog: next post number, smolweb-valid template, index listing and feed regeneration, with a lint that catches the mistakes that silently break RSS.",
"version": "0.1.0",
"author": {
"name": "profullstack",
"url": "https://profullstack.com"
},
"homepage": "https://github.com/profullstack/cli-tools#blog",
"license": "MIT",
"keywords": ["blog", "rss", "feed", "publishing", "smolweb"]
}
49 changes: 49 additions & 0 deletions plugins/blog/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# blog — publish to the plain-HTML blog 📝

Slash commands wrapping the `blog-post` CLI. The blog at
`~/public_html/blog` has no build step and no CMS: writing a file *is*
publishing. That is the appeal and also the hazard, because nothing catches a
mistake before it is live.

| command | what it does |
| --- | --- |
| `/blog:post <title>` | draft, create and publish a post, then rebuild the feed |
| `/blog:check` | find posts that will break the feed |
| `/blog:list` | every post with its date |
| `/blog:feed` | regenerate `feed.xml` |

## What this stops you doing

**Dating a post in the future.** It sorts above everything real, and readers
that filter future items drop it, so the feed looks like it stopped updating
while every file on disk looks perfect. Three posts once sat 7–10 hours ahead
and did exactly that. `blog-post` refuses a future date unless you insist.

**Omitting `<meta name="date">`.** `build-feed.mjs` skips the post without
saying anything useful.

**Breaking smolweb validity.** The generated template uses an explicit
`<html lang>`, `<meta http-equiv="Content-Type">` rather than a bare
`<meta charset>`, and closes everything.

**Forgetting the AI-drafting acknowledgment.** It goes in every post; Kagi
Small Web and others require disclosure, and the index states the policy.

## Install

```bash
moshcode plugin marketplace add profullstack/cli-tools
moshcode plugin install blog@cli-tools
```

The commands shell out to `blog-post`, which comes from this same repo. It is
not published to npm — clone and link it onto `PATH`:

```sh
git clone git@github.com:profullstack/cli-tools.git ~/src/profullstack/cli-tools
cd ~/src/profullstack/cli-tools
pnpm install
pnpm link:bin
```

Point it at a different blog with `--dir` or `$BLOG_DIR`.
37 changes: 37 additions & 0 deletions plugins/blog/commands/check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
description: Find posts that will break the RSS feed — missing dates, future dates, empty summaries.
allowed-tools: Bash(blog-post:*), Read, Edit
---

## Task

Check the blog for posts that will not appear correctly in the feed.

```bash
blog-post check
```

## What it looks for

- **A date in the future.** The important one. Such a post sorts above every
real post and is dropped entirely by readers that hide future items, so the
feed appears to have stopped updating while everything looks fine on disk.
Three posts once sat 7&ndash;10 hours ahead and did exactly that.
- **No `<meta name="date">`.** `build-feed.mjs` skips the post silently.
- **An unparseable date.** Same outcome, also silent.
- **No `<meta name="description">`.** The item ships with an empty summary.
- **No `<h1>`.** The feed title falls back to `<title>`, which carries the
site-name suffix.

## Fixing

Edit the offending `<meta>` in the post, then rebuild:

```bash
blog-post feed
```

For a wrong date, prefer the file's real modification time over inventing one —
that is the best evidence of when the post was actually written.

Exit status is non-zero when anything is wrong, so this is usable as a gate.
23 changes: 23 additions & 0 deletions plugins/blog/commands/feed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
description: Regenerate feed.xml from the posts on disk.
allowed-tools: Bash(blog-post:*), Bash(node:*)
---

## Task

Rebuild the RSS feed.

```bash
blog-post feed
```

This runs the blog's own `build-feed.mjs`, which is the single source of truth
for the feed's shape. It keeps the **10 most recent** posts and trims the rest,
and warns about any post dated in the future.

Run it after editing a post's title, date or description by hand —
`/blog:post` already does it for you when creating one.

The feed is served straight off disk at
`https://dev.profullstack.com/~anthony/blog/feed.xml`, so it is live the moment
the file is written. There is nothing to deploy.
Loading
Loading