Skip to content

Commit d866169

Browse files
ralyodioclaude
andcommitted
Add blog-post, and ship cli-tools as a moshcode plugin marketplace
The blog at ~/public_html/blog has no build step — writing a file is publishing — so every convention lives only in the existing files and nothing catches a mistake before it is live. Three posts were recently dated 7-10 hours in the future, which pinned them above every real post and made the feed look like it had stopped updating. blog-post encodes the conventions: next NNN-post.html, the smolweb-valid template with the AI-drafting acknowledgment, the index.html entry, and a build-feed.mjs run. It refuses a future date unless forced, requires the description that becomes the RSS summary, and writes with 'wx' so two concurrent runs cannot land on the same number and lose a post. `blog-post check` reports what silently breaks the feed: missing or unparseable dates, future dates, empty summaries, missing h1. Non-zero exit, so it works as a gate. Also makes the repo a plugin marketplace, exposing /blog:post, /blog:check, /blog:list and /blog:feed in moshcode. 11 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent ed9e956 commit d866169

12 files changed

Lines changed: 955 additions & 1 deletion

File tree

.claude-plugin/marketplace.json

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
3+
"name": "cli-tools",
4+
"description": "Profullstack's command-line tools as installable plugins — publish to the plain-HTML blog without getting a convention wrong.",
5+
"owner": {
6+
"name": "profullstack",
7+
"url": "https://profullstack.com"
8+
},
9+
"plugins": [
10+
{
11+
"name": "blog",
12+
"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.",
13+
"source": "./plugins/blog",
14+
"category": "productivity",
15+
"author": {
16+
"name": "profullstack",
17+
"url": "https://profullstack.com"
18+
},
19+
"homepage": "https://github.com/profullstack/cli-tools#blog",
20+
"keywords": ["blog", "rss", "feed", "publishing", "smolweb"]
21+
}
22+
]
23+
}

README.md

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,67 @@
1-
# cli-tools
1+
# cli-tools
2+
3+
Command-line tools for working across a lot of repositories at once — and a
4+
moshcode/Claude Code plugin marketplace wrapping them as slash commands.
5+
6+
```bash
7+
npm i -g @profullstack/cli-tools
8+
```
9+
10+
## blog-post
11+
12+
Publishes to the plain-HTML blog at `~/public_html/blog`. That blog has no build
13+
step and no CMS: writing a file *is* publishing. This tool exists because
14+
nothing else catches a mistake before it is live.
15+
16+
```bash
17+
blog-post new "A title" -d "The one-line feed summary" --body draft.html
18+
blog-post check # posts that will break the feed
19+
blog-post list # every post with its date
20+
blog-post feed # regenerate feed.xml
21+
```
22+
23+
`new` picks the next `NNN-post.html`, renders the smolweb-valid template with
24+
the AI-drafting acknowledgment, splices the entry into `index.html`, and runs
25+
`build-feed.mjs`.
26+
27+
Point it elsewhere with `--dir` or `$BLOG_DIR`.
28+
29+
### What it refuses to do
30+
31+
- **Date a post in the future.** Such a post sorts above every real post, and
32+
readers that filter future items drop it entirely — so the feed looks like it
33+
stopped updating while the files on disk look perfect. This has happened once
34+
already: three posts sat 7–10 hours ahead. Pass `--allow-future` only if you
35+
genuinely mean to schedule.
36+
- **Overwrite an existing post.** Two concurrent runs would otherwise pick the
37+
same number; the write uses `wx`, so the loser fails loudly instead of
38+
silently replacing a post.
39+
- **Skip the description.** It is the whole RSS summary, so it is required.
40+
41+
### What `check` catches
42+
43+
Missing or unparseable `<meta name="date">` (the feed generator skips those
44+
posts in silence), future dates, empty descriptions, and a missing `<h1>`. Exit
45+
status is non-zero when anything is wrong, so it works as a pre-publish gate.
46+
47+
## As a moshcode plugin
48+
49+
This repo is also a plugin marketplace:
50+
51+
```bash
52+
moshcode plugin marketplace add profullstack/cli-tools
53+
moshcode plugin install blog@cli-tools
54+
```
55+
56+
That adds `/blog:post`, `/blog:check`, `/blog:list` and `/blog:feed`. See
57+
[plugins/blog](plugins/blog/README.md).
58+
59+
## Tests
60+
61+
```bash
62+
pnpm test
63+
```
64+
65+
## Licence
66+
67+
MIT © Profullstack, Inc.

bin/blog-post.mjs

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
#!/usr/bin/env node
2+
import { spawnSync } from 'node:child_process';
3+
import { existsSync } from 'node:fs';
4+
import { join } from 'node:path';
5+
6+
import { blogDir, createPost, lint, readPosts, DEFAULT_DIR } from '../src/blog.mjs';
7+
8+
const HELP = `blog-post — write to the plain-HTML blog without getting a convention wrong
9+
10+
Usage
11+
blog-post new <title> -d <description> [--body file.html] [--date ISO]
12+
blog-post check Report posts that will break the feed
13+
blog-post list Every post with its date
14+
blog-post feed Regenerate feed.xml
15+
16+
Options
17+
-d, --description <text> Feed summary. Required for a new post.
18+
--body <file> HTML body to drop in (default: a stub)
19+
--date <iso> Publish date (default: now). Refuses the future.
20+
--dir <path> Blog directory (default: $BLOG_DIR or
21+
${DEFAULT_DIR})
22+
--allow-future Permit a future date. You almost never want this.
23+
-h, --help
24+
25+
Why this exists
26+
The blog has no build step, so nothing catches a mistake. A post with no
27+
<meta name="date"> is skipped by the feed generator entirely; one dated in
28+
the future pins itself above everything and is hidden outright by readers
29+
that filter future items — the feed looks dead while the files look fine.
30+
\`blog-post check\` finds both.
31+
`;
32+
33+
/**
34+
* @param {string[]} argv
35+
* @returns {{ command: string, args: string[], flags: Record<string, string|boolean> }}
36+
*/
37+
export function parseArgs(argv) {
38+
const flags = {};
39+
const args = [];
40+
let command = '';
41+
42+
const alias = { d: 'description', h: 'help' };
43+
44+
for (let i = 0; i < argv.length; i += 1) {
45+
const token = argv[i];
46+
47+
if (token === '-h' || token === '--help') {
48+
flags.help = true;
49+
} else if (token.startsWith('--') || (token.startsWith('-') && token.length === 2)) {
50+
const key = alias[token.replace(/^-+/, '')] ?? token.replace(/^--/, '');
51+
const next = argv[i + 1];
52+
if (next === undefined || next.startsWith('--')) {
53+
flags[key] = true;
54+
} else {
55+
flags[key] = next;
56+
i += 1;
57+
}
58+
} else if (!command) {
59+
command = token;
60+
} else {
61+
args.push(token);
62+
}
63+
}
64+
65+
return { command, args, flags };
66+
}
67+
68+
/**
69+
* Regenerate feed.xml by running the blog's own generator.
70+
*
71+
* Shelling out rather than reimplementing: build-feed.mjs lives with the blog
72+
* and is the single source of truth for what the feed looks like.
73+
*
74+
* @param {string} dir
75+
* @returns {number} exit code
76+
*/
77+
function rebuildFeed(dir) {
78+
const script = join(dir, 'build-feed.mjs');
79+
if (!existsSync(script)) {
80+
console.error(`no build-feed.mjs in ${dir} — feed not regenerated`);
81+
return 1;
82+
}
83+
const res = spawnSync(process.execPath, [script], { cwd: dir, stdio: 'inherit' });
84+
return res.status ?? 1;
85+
}
86+
87+
/**
88+
* @param {string[]} argv
89+
* @returns {Promise<number>}
90+
*/
91+
export async function run(argv) {
92+
const { command, args, flags } = parseArgs(argv);
93+
94+
if (flags.help || !command) {
95+
console.log(HELP);
96+
return command ? 0 : 1;
97+
}
98+
99+
const dir = blogDir(typeof flags.dir === 'string' ? flags.dir : undefined);
100+
101+
if (!existsSync(dir)) {
102+
console.error(`blog directory not found: ${dir}`);
103+
return 1;
104+
}
105+
106+
switch (command) {
107+
case 'new': {
108+
const title = args.join(' ').trim();
109+
if (!title) {
110+
console.error('new: give a title');
111+
return 1;
112+
}
113+
114+
const description = typeof flags.description === 'string' ? flags.description.trim() : '';
115+
if (!description) {
116+
console.error('new: -d/--description is required (it becomes the feed summary)');
117+
return 1;
118+
}
119+
120+
const date =
121+
typeof flags.date === 'string' ? flags.date : new Date().toISOString().replace(/\.\d+Z$/, 'Z');
122+
123+
const when = new Date(date);
124+
if (Number.isNaN(when.getTime())) {
125+
console.error(`new: unparseable --date "${date}"`);
126+
return 1;
127+
}
128+
if (when.getTime() > Date.now() && !flags['allow-future']) {
129+
console.error(
130+
`new: ${date} is in the future. A future-dated post sits above every real post and\n` +
131+
' readers that hide future items drop it, so the feed looks dead. Pass\n' +
132+
' --allow-future only if you genuinely mean to schedule it.',
133+
);
134+
return 1;
135+
}
136+
137+
let body = '';
138+
if (typeof flags.body === 'string') {
139+
body = await (await import('node:fs/promises')).readFile(flags.body, 'utf8');
140+
}
141+
142+
const { file, path } = await createPost(dir, {
143+
title,
144+
description,
145+
date: when.toISOString().replace(/\.\d+Z$/, 'Z'),
146+
body,
147+
});
148+
149+
console.log(`created ${file}`);
150+
console.log(` ${path}`);
151+
console.log(' listed in index.html');
152+
return rebuildFeed(dir);
153+
}
154+
155+
case 'check': {
156+
const problems = lint(await readPosts(dir));
157+
if (problems.length === 0) {
158+
console.log('all posts look publishable');
159+
return 0;
160+
}
161+
for (const p of problems) console.error(`${p.file}: ${p.problem}`);
162+
return 1;
163+
}
164+
165+
case 'list': {
166+
for (const p of await readPosts(dir)) {
167+
const title = (p.title ?? '(no h1)').replace(/&mdash;/g, '—').slice(0, 52);
168+
console.log(`${p.file} ${(p.date ?? 'NO-DATE').padEnd(22)} ${title}`);
169+
}
170+
return 0;
171+
}
172+
173+
case 'feed':
174+
return rebuildFeed(dir);
175+
176+
default:
177+
console.error(`unknown command: ${command}\n`);
178+
console.error(HELP);
179+
return 1;
180+
}
181+
}
182+
183+
process.exitCode = await run(process.argv.slice(2));

package.json

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
{
2+
"name": "@profullstack/cli-tools",
3+
"version": "0.1.0",
4+
"description": "Command-line tools for working across a lot of repositories at once, plus the blog publishing tool",
5+
"type": "module",
6+
"bin": {
7+
"blog-post": "./bin/blog-post.mjs"
8+
},
9+
"files": [
10+
"bin",
11+
"src",
12+
"plugins",
13+
".claude-plugin",
14+
"README.md"
15+
],
16+
"scripts": {
17+
"test": "node --test test/*.test.mjs"
18+
},
19+
"engines": {
20+
"node": ">=22"
21+
},
22+
"keywords": [
23+
"cli",
24+
"blog",
25+
"rss",
26+
"moshcode",
27+
"claude-code",
28+
"plugin"
29+
],
30+
"repository": {
31+
"type": "git",
32+
"url": "https://github.com/profullstack/cli-tools.git"
33+
},
34+
"homepage": "https://github.com/profullstack/cli-tools",
35+
"license": "MIT"
36+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3+
"name": "blog",
4+
"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.",
5+
"version": "0.1.0",
6+
"author": {
7+
"name": "profullstack",
8+
"url": "https://profullstack.com"
9+
},
10+
"homepage": "https://github.com/profullstack/cli-tools#blog",
11+
"license": "MIT",
12+
"keywords": ["blog", "rss", "feed", "publishing", "smolweb"]
13+
}

plugins/blog/README.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# blog — publish to the plain-HTML blog 📝
2+
3+
Slash commands wrapping the `blog-post` CLI. The blog at
4+
`~/public_html/blog` has no build step and no CMS: writing a file *is*
5+
publishing. That is the appeal and also the hazard, because nothing catches a
6+
mistake before it is live.
7+
8+
| command | what it does |
9+
| --- | --- |
10+
| `/blog:post <title>` | draft, create and publish a post, then rebuild the feed |
11+
| `/blog:check` | find posts that will break the feed |
12+
| `/blog:list` | every post with its date |
13+
| `/blog:feed` | regenerate `feed.xml` |
14+
15+
## What this stops you doing
16+
17+
**Dating a post in the future.** It sorts above everything real, and readers
18+
that filter future items drop it, so the feed looks like it stopped updating
19+
while every file on disk looks perfect. Three posts once sat 7–10 hours ahead
20+
and did exactly that. `blog-post` refuses a future date unless you insist.
21+
22+
**Omitting `<meta name="date">`.** `build-feed.mjs` skips the post without
23+
saying anything useful.
24+
25+
**Breaking smolweb validity.** The generated template uses an explicit
26+
`<html lang>`, `<meta http-equiv="Content-Type">` rather than a bare
27+
`<meta charset>`, and closes everything.
28+
29+
**Forgetting the AI-drafting acknowledgment.** It goes in every post; Kagi
30+
Small Web and others require disclosure, and the index states the policy.
31+
32+
## Install
33+
34+
```bash
35+
moshcode plugin marketplace add profullstack/cli-tools
36+
moshcode plugin install blog@cli-tools
37+
```
38+
39+
The commands shell out to `blog-post`, so install that too:
40+
41+
```bash
42+
npm i -g @profullstack/cli-tools
43+
```
44+
45+
Point it at a different blog with `--dir` or `$BLOG_DIR`.

0 commit comments

Comments
 (0)