diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index b7d88c5..065ba24 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -7,6 +7,18 @@ "url": "https://profullstack.com" }, "plugins": [ + { + "name": "tools", + "description": "Install and manage the cli-tools command set: put every command on PATH, keep the checkout current, and wire up the moshcode pit aliases.", + "source": "./plugins/tools", + "category": "productivity", + "author": { + "name": "profullstack", + "url": "https://profullstack.com" + }, + "homepage": "https://github.com/profullstack/cli-tools#install", + "keywords": ["cli", "install", "path", "aliases", "moshcode"] + }, { "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.", diff --git a/.gitignore b/.gitignore index 3c45938..7380fcc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,14 @@ node_modules/ *.log .DS_Store + +# Your blog identity: byline, rel="me" links, analytics and ad account ids. +# Belongs in ~/.config/cli-tools/blog.json, never in the repository. +blog.config.json + +# Local environment and credentials, in every form they usually turn up in. +.env +.env.* +!.env.example +*.pem +*.key diff --git a/README.md b/README.md index 35a2c71..51ef52d 100644 --- a/README.md +++ b/README.md @@ -5,12 +5,14 @@ TypeScript, installed as executables on `PATH`. | Command | What it does | | --- | --- | +| [`cli-tools`](#install) | The dispatcher: list, update, link, and the pit aliases | | [`gh-prs`](#gh-prs) | List every open PR across the owners you name | | [`gh-prs-merge`](#gh-prs-merge) | Squash-merge the PRs that are genuinely ready | | [`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 | +| [`domainfree`](#domainfree) | Which of these domains you can actually register | +| [`blog-post`](#blog-post) | Publish to a plain-HTML blog without breaking the feed | ## Requirements @@ -23,12 +25,39 @@ TypeScript, installed as executables on `PATH`. ## Install +```sh +curl -fsSL https://raw.githubusercontent.com/profullstack/cli-tools/master/install.sh | sh +``` + +That clones to `~/.local/share/cli-tools`, installs dependencies, and symlinks +every command into `~/.local/bin`. `CLI_TOOLS_HOME` and `CLI_TOOLS_PREFIX` +override both. If a checkout already owns these command names, the installer +updates *that* one rather than cloning a second copy beside it. + +With moshcode on the box, the same thing: + +```sh +moshcode install cli-tools # then /cli-tools … in the pit +``` + +Check what landed, and wire up the pit aliases: + +```sh +cli-tools list # a * marks each command found on PATH +cli-tools aliases --install # /blog /free /merge /prs /whois +cli-tools update # git pull, reinstall, relink +``` + +
+From a clone, for development + ```sh git clone git@github.com:profullstack/cli-tools.git ~/src/profullstack/cli-tools cd ~/src/profullstack/cli-tools pnpm install pnpm link:bin ``` +
`link:bin` symlinks every `bin/*.ts` into `~/.local/bin` without the extension, so `gh-prs-merge` is a real command. (Not named `link` — that is a pnpm builtin, @@ -71,8 +100,8 @@ and URL become clickable. ```sh gh-prs --orgs profullstack,moshcoder,h4kr,infernetprotocol -gh-prs --users ralyodio -gh-prs --orgs profullstack --users ralyodio --limit 50 +gh-prs --users octocat +gh-prs --orgs profullstack --users octocat --limit 50 gh-prs --orgs profullstack --no-links # plain text, for piping ``` @@ -226,12 +255,49 @@ 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 +blog-post config # where your identity is read from, and what is in effect ``` -`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`. +`new` picks the next `NNN-post.html`, renders the smolweb-valid template, +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`. + +#### Your identity is configuration, not code + +Nothing about *you* is baked into this repository. The byline, the site name, +the `rel="me"` links and any analytics or ad ids come from a config file, and +with none present a post renders with no byline, no identity links and **no +third-party scripts at all** — which is the only fully smolweb-valid output. + +Copy [`blog.config.example.json`](blog.config.example.json) to whichever of +these suits, most specific first: + +| Path | Use it for | +| --- | --- | +| `$BLOG_CONFIG` | a one-off, or CI | +| `/blog.config.json` | a second blog with its own identity | +| `~/.config/cli-tools/blog.json` | your own blog — the usual answer | + +```json +{ + "siteTitle": "Your Blog", + "author": "Your Name", + "disclosure": "How this was written: drafted with an AI assistant, then edited by me.", + "links": [{ "label": "Mastodon", "href": "https://example.social/@you" }], + "trackerSiteId": null, + "adSlotId": null +} +``` + +`BLOG_SITE_TITLE`, `BLOG_AUTHOR`, `BLOG_DISCLOSURE`, `CRAWLPROOF_SITE_ID`, +`CRAWLPROOF_AD_SLOT` and `CRAWLPROOF_AD_FORMAT` override the file. `links` is +the only field with no environment equivalent. + +`trackerSiteId` and `adSlotId` are **accounts, not settings**: leave them null +unless they are yours. A shared id would meter your readers' pageviews and your +ad impressions into somebody else's account, which is why they are not defaults. + +Run `blog-post config` to see which file was picked up and what it resolved to. What it refuses to do: @@ -250,19 +316,33 @@ missing `

`, 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: +This repo is also a plugin marketplace: ```sh moshcode plugin marketplace add profullstack/cli-tools -moshcode plugin install blog@cli-tools +moshcode plugin install tools@cli-tools # /tools:install, /tools:list +moshcode plugin install blog@cli-tools # /blog:post, :check, :list, :feed +moshcode plugin install domain@cli-tools # /domain:free, /domain:lookup ``` -That adds `/blog:post`, `/blog:check`, `/blog:list` and `/blog:feed`. See -[plugins/blog](plugins/blog/README.md). +See [plugins/tools](plugins/tools/README.md), [plugins/blog](plugins/blog/README.md) +and [plugins/domain](plugins/domain/README.md). + +`cli-tools` is also a moshcode workflow tool, so the whole set installs and +updates through moshcode itself: + +```sh +moshcode install cli-tools # then /cli-tools list, /cli-tools update +``` ## Aliases -Pit aliases live in `~/.moshcode/aliases.json`: +Pit aliases live in `~/.moshcode/aliases.json`. `cli-tools aliases --install` +writes a thin default set (`/blog`, `/free`, `/merge`, `/prs`, `/whois`), +merging rather than replacing — an alias you bound yourself is kept and the +collision is reported. `cli-tools aliases` prints them without writing anything. + +To manage them by hand: ``` /alias set prs "gh-prs --orgs profullstack" diff --git a/bin/blog-post.ts b/bin/blog-post.ts index 39aef1d..10e3f4f 100755 --- a/bin/blog-post.ts +++ b/bin/blog-post.ts @@ -23,18 +23,21 @@ import { lint, readPosts, } from '../src/blog.ts'; +import { configPaths, loadBlogConfig } from '../src/blog-config.ts'; const USAGE = `Usage: blog-post new --description <text> [--body file.html] [--date ISO] blog-post check blog-post list blog-post feed + blog-post config 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 + config Where the blog identity is read from, and what is in effect Options: --description TEXT Feed summary. Required by \`new\`. @@ -126,12 +129,19 @@ export async function run(argv: readonly string[]): Promise<number> { const bodyFile = values.get('--body'); const body = bodyFile ? await readFile(bodyFile, 'utf8') : ''; - const { file, path } = await createPost(dir, { - title, - description, - date: isoSeconds(when), - body, - }); + const config = await loadBlogConfig(dir); + if (!config.author) { + process.stderr.write( + 'note: no blog config found, so this post has no byline and no identity links.\n' + + ` Write one to ${configPaths(dir).at(-1)} — see \`blog-post config\`.\n`, + ); + } + + const { file, path } = await createPost( + dir, + { title, description, date: isoSeconds(when), body }, + config, + ); process.stdout.write(`created ${file}\n ${path}\n listed in index.html\n`); return rebuildFeed(dir); @@ -157,6 +167,24 @@ export async function run(argv: readonly string[]): Promise<number> { return 0; } + case 'config': { + const config = await loadBlogConfig(dir); + const paths = configPaths(dir); + process.stdout.write('Config is read from the first of these that exists:\n'); + for (const path of paths) { + process.stdout.write(` ${existsSync(path) ? '*' : ' '} ${path}\n`); + } + process.stdout.write(`\nIn effect:\n${JSON.stringify(config, null, 2)}\n`); + if (!config.author) { + process.stdout.write( + '\nNothing is configured, so posts render with no byline, no identity links\n' + + 'and no third-party scripts. Copy blog.config.example.json to\n' + + `${paths.at(-1)} and fill it in.\n`, + ); + } + return 0; + } + case 'feed': return rebuildFeed(dir); diff --git a/bin/cli-tools.ts b/bin/cli-tools.ts new file mode 100755 index 0000000..3c3f79b --- /dev/null +++ b/bin/cli-tools.ts @@ -0,0 +1,226 @@ +#!/usr/bin/env -S npx --yes tsx +/** + * cli-tools — the front door to everything else in this repository. + * + * It exists so the whole set has one name. `moshcode install cli-tools` looks + * for a binary called `cli-tools` to decide whether the tool is present, and + * the pit passes `/cli-tools …` straight through to it, so a single command + * covers install status, updates, and reaching any of the others. + * + * cli-tools list what is installed, and what is on PATH + * cli-tools update pull and relink + * cli-tools link [--force] symlink the commands into ~/.local/bin + * cli-tools unlink remove the ones we own + * cli-tools aliases [--install] the moshcode pit aliases + * cli-tools <command> [args…] run one of the commands directly + */ + +import { spawnSync } from 'node:child_process'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +import { parseArgs, UsageError } from '../src/args.ts'; +import { isMain } from '../src/is-main.ts'; +import { + aliasesPath, + commands, + mergeAliases, + onPath, + PIT_ALIASES, + repoRoot, +} from '../src/registry.ts'; + +const USAGE = `Usage: + cli-tools list + cli-tools update + cli-tools link [--force] + cli-tools unlink + cli-tools aliases [--install] + cli-tools <command> [args…] + +Commands: + list Every command here, and whether it is on PATH + update git pull, reinstall dependencies, relink + link Symlink the commands into ~/.local/bin + unlink Remove the symlinks we own + aliases Print the moshcode pit aliases, or write them with --install + where Print the checkout this command is running from + +Options: + --force link: take over a symlink owned by another checkout + --install aliases: merge them into ~/.moshcode/aliases.json + --json list/aliases: machine-readable + -h, --help +`; + +const SPEC = { + boolean: ['--force', '--install', '--json', '-h', '--help'], + string: [], +} as const; + +function runLinks(root: string, args: readonly string[]): number { + const script = join(root, 'scripts', 'install-links.mjs'); + return spawnSync(process.execPath, [script, ...args], { cwd: root, stdio: 'inherit' }).status ?? 1; +} + +/** Pull and relink. Dependencies come first so a new one is present before use. */ +function update(root: string): number { + const git = spawnSync('git', ['pull', '--ff-only'], { cwd: root, stdio: 'inherit' }); + if (git.status !== 0) { + process.stderr.write( + 'update: git pull failed. A dirty tree or a diverged branch stops this on purpose —\n' + + ` nothing here discards your work. Sort it out in ${root} and retry.\n`, + ); + return git.status ?? 1; + } + + const pnpm = spawnSync('pnpm', ['install', '--silent'], { cwd: root, stdio: 'inherit' }); + if (pnpm.error) { + const npm = spawnSync('npm', ['install', '--silent', '--no-audit', '--no-fund'], { + cwd: root, + stdio: 'inherit', + }); + if (npm.status !== 0) return npm.status ?? 1; + } else if (pnpm.status !== 0) { + return pnpm.status ?? 1; + } + + return runLinks(root, []); +} + +function writeAliases(): number { + const path = aliasesPath(); + let existing: Record<string, string> = {}; + + try { + const parsed: unknown = JSON.parse(readFileSync(path, 'utf8')); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + existing = parsed as Record<string, string>; + } + } catch (error) { + // A missing file is the first-run case. Anything else means the pit has a + // file we would be overwriting blind, and its aliases are the operator's. + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + process.stderr.write(`aliases: ${path} exists but is not readable JSON — not touching it.\n`); + return 1; + } + } + + const { merged, added, kept } = mergeAliases(existing); + + if (added.length === 0) { + process.stdout.write(`aliases: nothing to add — ${path} is already up to date.\n`); + } else { + mkdirSync(dirname(path), { recursive: true }); + // 0600 to match how the pit writes it: an alias list is a working habit. + writeFileSync(path, `${JSON.stringify(merged, null, 2)}\n`, { mode: 0o600 }); + process.stdout.write(`aliases: added ${added.join(', ')} to ${path}\n`); + } + + for (const name of kept) { + process.stdout.write( + `aliases: kept your own "${name}" (${existing[name]}) — ours would have been "${PIT_ALIASES[name]}"\n`, + ); + } + + process.stdout.write('\nThe pit re-reads the file on every lookup, so an open pit has them now.\n'); + return 0; +} + +export async function run(argv: readonly string[]): Promise<number> { + // The first word is the command, and everything after it belongs to that + // command — parsed here only for our own verbs, and passed through untouched + // for the others. Parsing the whole line up front would mean this dispatcher + // had to know every flag every tool accepts, and would reject the ones it + // did not. + const command = argv[0]; + const rest = argv.slice(1); + const root = repoRoot(); + + if (!command || command === '-h' || command === '--help') { + process.stdout.write(USAGE); + return command ? 0 : 1; + } + + // Anything that is not one of ours is one of the commands: pass it straight + // through, arguments and streams untouched, so `cli-tools gh-prs --orgs x` + // behaves exactly as `gh-prs --orgs x` does. + const known = new Set(['list', 'update', 'link', 'unlink', 'aliases', 'where']); + if (!known.has(command)) { + const match = commands(root).find((entry) => entry.name === command); + if (!match) { + process.stderr.write(`unknown command: ${command}\n\n${USAGE}`); + return 1; + } + const script = join(root, 'bin', `${match.name}.ts`); + return spawnSync(script, rest, { stdio: 'inherit' }).status ?? 1; + } + + let options; + try { + options = parseArgs(rest, SPEC); + } catch (error) { + if (error instanceof UsageError) { + process.stderr.write(`${error.message}\n\n${USAGE}`); + return 2; + } + throw error; + } + + switch (command) { + case 'where': + process.stdout.write(`${root}\n`); + return 0; + + case 'list': { + const all = commands(root).map((entry) => ({ ...entry, onPath: onPath(entry.name) })); + if (options.flags.has('--json')) { + process.stdout.write(`${JSON.stringify({ root, commands: all }, null, 2)}\n`); + return 0; + } + process.stdout.write(`${root}\n\n`); + for (const entry of all) { + const mark = entry.onPath ? '*' : ' '; + process.stdout.write(`${mark} ${entry.name.padEnd(16)} ${entry.summary}\n`); + } + const missing = all.filter((entry) => !entry.onPath).length; + process.stdout.write( + missing === 0 + ? '\nAll on PATH.\n' + : `\n${missing} not on PATH — run \`cli-tools link\`.\n`, + ); + return 0; + } + + case 'update': + return update(root); + + case 'link': + return runLinks(root, options.flags.has('--force') ? ['--force'] : []); + + case 'unlink': + return runLinks(root, ['--remove']); + + case 'aliases': { + if (options.flags.has('--install')) return writeAliases(); + if (options.flags.has('--json')) { + process.stdout.write(`${JSON.stringify(PIT_ALIASES, null, 2)}\n`); + return 0; + } + process.stdout.write(`Suggested moshcode pit aliases (${aliasesPath()}):\n\n`); + for (const [name, value] of Object.entries(PIT_ALIASES)) { + process.stdout.write(` /${name.padEnd(8)} ${value}\n`); + } + process.stdout.write('\nWrite them with `cli-tools aliases --install`.\n'); + return 0; + } + + 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)); +} diff --git a/bin/gh-prs.ts b/bin/gh-prs.ts index fde347e..003d022 100755 --- a/bin/gh-prs.ts +++ b/bin/gh-prs.ts @@ -3,8 +3,8 @@ * gh-prs — list every open pull request across the owners you name. * * gh-prs --orgs profullstack,moshcoder,h4kr,infernetprotocol - * gh-prs --users ralyodio,devpreshy - * gh-prs --orgs profullstack --users ralyodio + * gh-prs --users octocat,hubot + * gh-prs --orgs profullstack --users octocat */ import { csv, integer, parseArgs, UsageError } from '../src/args.ts'; @@ -24,7 +24,7 @@ Options: Examples: gh-prs --orgs profullstack,moshcoder,h4kr,infernetprotocol - gh-prs --users ralyodio,devpreshy + gh-prs --users octocat,hubot `; async function main(argv: string[]): Promise<number> { diff --git a/blog.config.example.json b/blog.config.example.json new file mode 100644 index 0000000..ce9a54c --- /dev/null +++ b/blog.config.example.json @@ -0,0 +1,13 @@ +{ + "siteTitle": "Your Blog", + "author": "Your Name", + "disclosure": "<strong>How this was written:</strong> drafted with an AI assistant from my own notes, then edited by me.", + "links": [ + { "label": "Mastodon", "href": "https://example.social/@you" }, + { "label": "GitHub", "href": "https://github.com/you" }, + { "label": "email", "href": "mailto:you@example.com" } + ], + "trackerSiteId": null, + "adSlotId": null, + "adFormat": "text_link" +} diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..abd1dd3 --- /dev/null +++ b/install.sh @@ -0,0 +1,109 @@ +#!/bin/sh +# cli-tools installer. +# +# curl -fsSL https://raw.githubusercontent.com/profullstack/cli-tools/master/install.sh | sh +# +# Clones (or updates) the repository, installs its dependencies, and symlinks +# every command into ~/.local/bin. POSIX sh on purpose: this is what +# `moshcode install cli-tools` runs, and that runs it with `sh -c`. +# +# Environment: +# CLI_TOOLS_HOME where the checkout lives (default ~/.local/share/cli-tools) +# CLI_TOOLS_PREFIX where the commands are linked (default ~/.local/bin) +# CLI_TOOLS_REPO clone URL +# CLI_TOOLS_BRANCH branch to track (default master) +# CLI_TOOLS_FORCE set to 1 to take over links owned by another checkout + +set -eu + +REPO="${CLI_TOOLS_REPO:-https://github.com/profullstack/cli-tools.git}" +BRANCH="${CLI_TOOLS_BRANCH:-master}" +PREFIX="${CLI_TOOLS_PREFIX:-$HOME/.local/bin}" + +say() { printf '%s\n' "$*"; } +die() { printf 'cli-tools: %s\n' "$*" >&2; exit 1; } + +need() { + command -v "$1" >/dev/null 2>&1 || die "$2" +} + +need git "git is required. Install it, then re-run this installer." +need node "Node 20 or newer is required. Install it, then re-run this installer." + +# Node 20 is the floor: the commands use `import ... with`-era syntax and the +# test runner assumes it. Checking here names the problem, rather than letting +# it surface as a parse error inside an unrelated command later. +NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0)" +[ "$NODE_MAJOR" -ge 20 ] || die "Node 20 or newer is required (found $(node -v))." + +# If a checkout already owns the installed commands, update THAT one rather than +# cloning a second copy beside it. Without this, a contributor working from +# ~/src/… would get a duplicate clone whose links are all refused as +# not-ours, and no explanation of why nothing changed. +existing_home() { + for probe in blog-post domainfree gh-prs cli-tools; do + link="$PREFIX/$probe" + [ -L "$link" ] || continue + resolved="$(cd "$(dirname "$link")" && readlink "$link")" || continue + case "$resolved" in + /*) ;; + *) resolved="$PREFIX/$resolved" ;; + esac + root="$(dirname "$(dirname "$resolved")")" + if [ -d "$root/.git" ] && [ -d "$root/bin" ]; then + printf '%s\n' "$root" + return 0 + fi + done + return 1 +} + +if [ -n "${CLI_TOOLS_HOME:-}" ]; then + HOME_DIR="$CLI_TOOLS_HOME" +elif HOME_DIR="$(existing_home)"; then + say "Using the checkout that already owns your commands: $HOME_DIR" +else + HOME_DIR="$HOME/.local/share/cli-tools" +fi + +if [ -d "$HOME_DIR/.git" ]; then + say "Updating $HOME_DIR" + # A contributor's checkout may sit on a feature branch with work on it. + # Fetching always and fast-forwarding only when it is safe means the + # installer keeps them current without ever discarding anything. + git -C "$HOME_DIR" fetch --quiet origin "$BRANCH" || die "could not fetch from origin." + if [ -z "$(git -C "$HOME_DIR" status --porcelain)" ]; then + git -C "$HOME_DIR" merge --quiet --ff-only "origin/$BRANCH" 2>/dev/null \ + || say " left on $(git -C "$HOME_DIR" rev-parse --abbrev-ref HEAD) — not a fast-forward, so nothing was moved." + else + say " working tree is dirty — fetched, but not moved." + fi +else + say "Cloning into $HOME_DIR" + mkdir -p "$(dirname "$HOME_DIR")" + git clone --quiet --branch "$BRANCH" "$REPO" "$HOME_DIR" \ + || die "clone failed. Is $REPO reachable?" +fi + +say "Installing dependencies" +if command -v pnpm >/dev/null 2>&1; then + # pnpm is what the lockfile is for; --prefer-offline keeps a re-run cheap. + (cd "$HOME_DIR" && pnpm install --silent --prefer-offline) || die "pnpm install failed." +else + # npm cannot read pnpm-lock.yaml, so this resolves fresh. It is the fallback, + # not the intent, but it produces a working tsx and that is what matters. + (cd "$HOME_DIR" && npm install --silent --no-audit --no-fund) || die "npm install failed." +fi + +say "Linking commands into $PREFIX" +LINK_ARGS="" +[ "${CLI_TOOLS_FORCE:-0}" = "1" ] && LINK_ARGS="--force" +# shellcheck disable=SC2086 +CLI_TOOLS_PREFIX="$PREFIX" node "$HOME_DIR/scripts/install-links.mjs" $LINK_ARGS + +# install-links.mjs already warns when $PREFIX is not on PATH, so there is +# deliberately no second warning here. +say "" +say "Installed. Try:" +say " cli-tools list # what landed, and what is on PATH" +say " cli-tools aliases --install # the moshcode pit aliases" diff --git a/plugins/blog/README.md b/plugins/blog/README.md index 8687924..0fb5157 100644 --- a/plugins/blog/README.md +++ b/plugins/blog/README.md @@ -27,7 +27,14 @@ saying anything useful. `<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. +Small Web and others require disclosure, and the index states the policy. It +comes from the `disclosure` field of your blog config — `blog-post config` +shows whether one is set. + +**Publishing with nobody's name on it.** The byline, site name and `rel="me"` +links are configuration, not constants, so an unconfigured checkout writes a +post with no byline and no identity links. Copy `blog.config.example.json` to +`~/.config/cli-tools/blog.json` before the first post. ## Install diff --git a/plugins/blog/commands/feed.md b/plugins/blog/commands/feed.md index b81b591..fbe095b 100644 --- a/plugins/blog/commands/feed.md +++ b/plugins/blog/commands/feed.md @@ -18,6 +18,5 @@ 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. +The feed is served straight off disk, so it is live the moment the file is +written. There is nothing to deploy. diff --git a/plugins/blog/commands/post.md b/plugins/blog/commands/post.md index ae62ca4..1de666d 100644 --- a/plugins/blog/commands/post.md +++ b/plugins/blog/commands/post.md @@ -45,7 +45,8 @@ right the first time rather than fixing it live. you almost never mean to. - **Keep the AI-drafting acknowledgment** the template inserts. Kagi Small Web and others require disclosure of heavy LLM use, and the index states the - policy. + policy. It comes from the `disclosure` field of the blog config, so if a post + renders without one, run `blog-post config` rather than pasting it by hand. - **Stay smolweb-valid**: every `<p>` closed, no bare `<meta charset>`, no unclosed tags. The template handles the shell; your body has to hold up its end. diff --git a/plugins/tools/.claude-plugin/plugin.json b/plugins/tools/.claude-plugin/plugin.json new file mode 100644 index 0000000..99ee3c9 --- /dev/null +++ b/plugins/tools/.claude-plugin/plugin.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://anthropic.com/claude-code/plugin.schema.json", + "name": "tools", + "description": "Install and manage the cli-tools command set: put every command on PATH, keep it current, and wire up the moshcode pit aliases.", + "version": "0.1.0", + "author": { + "name": "profullstack", + "url": "https://profullstack.com" + }, + "homepage": "https://github.com/profullstack/cli-tools#install", + "license": "MIT", + "keywords": ["cli", "install", "path", "aliases", "moshcode"] +} diff --git a/plugins/tools/README.md b/plugins/tools/README.md new file mode 100644 index 0000000..b2bb886 --- /dev/null +++ b/plugins/tools/README.md @@ -0,0 +1,37 @@ +# tools + +Install and manage the `cli-tools` command set. + +`/tools:install` puts every command on `PATH` and wires up the moshcode pit +aliases. `/tools:list` says which checkout they run from and what actually +landed. + +## Install + +```bash +moshcode plugin marketplace add profullstack/cli-tools +moshcode plugin install tools@cli-tools +``` + +Or install the commands directly, without the plugin: + +```bash +curl -fsSL https://raw.githubusercontent.com/profullstack/cli-tools/master/install.sh | sh +cli-tools aliases --install +``` + +## The thing worth knowing + +The installed commands are **symlinks into a working tree**, not a copied build. +Whatever branch the checkout sits on is the code that runs, so a merged PR does +not update the installed command and a checkout parked on an old branch produces +stale output with no warning. `cli-tools where` names the checkout; `cli-tools +update` pulls and relinks it, refusing to move a dirty or diverged tree rather +than discarding work. + +The commands are real executables rather than shell functions because a file +works from every caller — an interactive shell, `zsh -c`, a systemd unit, a CI +step — without anything having been sourced first. The pit aliases this installs +are a shorter word for a longer invocation, never what makes a command +reachable, and none of them shares a name with a command: a function beats +`PATH`, so a wrapper of the same name would silently shadow the file. diff --git a/plugins/tools/commands/install.md b/plugins/tools/commands/install.md new file mode 100644 index 0000000..936b1a4 --- /dev/null +++ b/plugins/tools/commands/install.md @@ -0,0 +1,110 @@ +--- +description: Install the cli-tools command set onto PATH, and wire up the pit aliases. +allowed-tools: Bash(cli-tools:*), Bash(curl:*), Bash(sh:*), Bash(moshcode:*), Read +--- + +## Task + +Put every `cli-tools` command on `PATH` and make it reachable from the moshcode +pit. + +If `cli-tools` is not installed yet, one line does the whole thing: + +```bash +curl -fsSL https://raw.githubusercontent.com/profullstack/cli-tools/master/install.sh | sh +``` + +Or, if moshcode is already on the box: + +```bash +moshcode install cli-tools +``` + +Then wire up the pit aliases: + +```bash +cli-tools aliases --install +``` + +## What lands where + +The installer clones to `~/.local/share/cli-tools` (override with +`CLI_TOOLS_HOME`), installs dependencies, and symlinks every command into +`~/.local/bin` (override with `CLI_TOOLS_PREFIX`): + +| Command | What it does | +| --- | --- | +| `blog-post` | Publish to a plain-HTML blog without breaking the feed | +| `cli-tools` | This dispatcher | +| `domainfree` | Which of these domains you can actually register | +| `domainjson` | whois-style, JSON-first name lookup | +| `gh-prs` | Every open PR across the owners you name | +| `gh-prs-fix-all` | Repair the open scan PRs that are broken because of us | +| `gh-prs-merge` | Squash-merge the PRs that are genuinely ready | +| `tcfeed` | Find repositories worth scanning, scan them, print a shortlist | + +Check what took: + +```bash +cli-tools list # a * marks each command found on PATH +``` + +## If it says a command is not on PATH + +Two causes, and `cli-tools list` tells them apart from the rest of the output. + +**`~/.local/bin` is not on `PATH`.** The installer warns about this at the end. +Add it to your shell profile: + +```bash +export PATH="$HOME/.local/bin:$PATH" +``` + +**The name is already taken by another checkout.** A symlink pointing at a +different clone is left alone, because taking it over silently would change +which code runs. `cli-tools link --force` takes over a *symlink*; a real file of +that name is refused either way. + +## Aliases are a convenience, not the mechanism + +`cli-tools aliases --install` merges these into `~/.moshcode/aliases.json`: + +| Alias | Expands to | +| --- | --- | +| `/blog` | `blog-post` | +| `/free` | `domainfree` | +| `/merge` | `gh-prs-merge --apply` | +| `/prs` | `gh-prs` | +| `/whois` | `domainjson` | + +An alias you already bound is never overwritten — the collision is reported and +yours is kept. The pit re-reads the file on every lookup, so an open pit picks +them up with no restart. Arguments append rather than substitute, so +`/merge --limit 5` works. + +None of these shares a name with a command, deliberately. A shell function beats +`PATH`, so an alias named after the file it wraps silently shadows it and the +two drift apart. Keep them thin for the same reason `/merge` carries only +`--apply`: `gh-prs-merge` already repairs by default under `--apply`, and baking +`--fix` in as well is what once made `/merge --fix` expand to +`--apply --fix --fix`. + +The commands are reachable from the pit whether or not you install any of this. +They are real executables on `PATH` because a file works from every caller — an +interactive shell, `zsh -c`, a systemd unit, a CI step — without anything having +been sourced first. The aliases only buy you a shorter word. + +## Keeping it current + +```bash +cli-tools update # git pull, reinstall dependencies, relink +``` + +`update` refuses to move a dirty or diverged checkout rather than discarding +work. If it stops, sort the checkout out at `cli-tools where` and retry. + +Note that the installed command runs **whatever branch the checkout is on** — +these are symlinks into a working tree, not a copied build. A checkout parked on +an old branch silently runs old code, so `cli-tools where` and a `git branch +--show-current` there are the first two things to check when a command behaves +like a version you do not recognise. diff --git a/plugins/tools/commands/list.md b/plugins/tools/commands/list.md new file mode 100644 index 0000000..8e616c7 --- /dev/null +++ b/plugins/tools/commands/list.md @@ -0,0 +1,36 @@ +--- +description: What cli-tools installs, which checkout it runs from, and what is on PATH. +allowed-tools: Bash(cli-tools:*), Read +--- + +## Task + +Report the state of the installed command set. + +```bash +cli-tools list # a * marks each command found on PATH +cli-tools list --json # the same, machine-readable +cli-tools where # the checkout the commands run from +``` + +## Reading it + +The first line is the checkout. That is the answer to most surprises here, +because these commands are **symlinks into a working tree**, not a copied +build: the command on `PATH` runs whatever branch that checkout happens to be +on. A tree parked on a stale branch silently produces stale output, and a flag +added by a merged PR answers `unknown option` until someone pulls. + +So when a command behaves like a version you do not recognise, check the +checkout before reading its source: + +```bash +cli-tools where +git -C "$(cli-tools where)" branch --show-current +git -C "$(cli-tools where)" log --oneline HEAD..origin/master +``` + +`cli-tools update` fixes the common case. + +A command with no `*` is not on `PATH` — see `/tools:install`, which covers both +causes. diff --git a/src/blog-config.ts b/src/blog-config.ts new file mode 100644 index 0000000..507da39 --- /dev/null +++ b/src/blog-config.ts @@ -0,0 +1,139 @@ +import { readFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +/** + * Who the blog belongs to, and which third-party ids its pages carry. + * + * None of this is a property of the *tool*, so none of it is baked into it. A + * byline, a Mastodon handle and an analytics site id are the author's, and a + * checkout that carried someone else's would publish their name on your posts + * and meter your pageviews and ad impressions into their account. So the + * defaults here are empty, every field is optional, and a post rendered without + * config is a clean post rather than a broken one: no byline, no identity + * links, and — the part that matters — no third-party scripts at all, which is + * the only configuration that is fully smolweb-valid. + */ + +export interface BlogLink { + label: string; + href: string; + /** Emitted as the anchor's `rel`. Defaults to `me`, which is what makes these verifiable. */ + rel?: string; +} + +export interface BlogConfig { + /** Site name, appended to each post's `<title>` and used as the feed link title. */ + siteTitle: string | null; + /** Byline name. Null omits the byline line entirely. */ + author: string | null; + /** Identity links in the footer. Empty omits the paragraph. */ + links: BlogLink[]; + /** How the post was written, as a short line under the byline. */ + disclosure: string | null; + /** CrawlProof site id for the pageview tag. Null emits no tracker. */ + trackerSiteId: string | null; + /** CrawlProof ad slot id. Null emits no ad unit. */ + adSlotId: string | null; + /** Ad format for the slot above. */ + adFormat: string; +} + +/** The zero config: a post with no identity and no third-party scripts. */ +export const EMPTY_CONFIG: BlogConfig = { + siteTitle: null, + author: null, + links: [], + disclosure: null, + trackerSiteId: null, + adSlotId: null, + adFormat: 'text_link', +}; + +function xdgConfigHome(env: NodeJS.ProcessEnv): string { + return env.XDG_CONFIG_HOME || join(homedir(), '.config'); +} + +/** + * Where a blog config may live, most specific first. + * + * The blog directory comes before the user directory so a second blog can carry + * its own identity without either one having to be passed on the command line. + */ +export function configPaths(dir?: string, env: NodeJS.ProcessEnv = process.env): string[] { + const paths: string[] = []; + if (env.BLOG_CONFIG) paths.push(env.BLOG_CONFIG); + if (dir) paths.push(join(dir, 'blog.config.json')); + paths.push(join(xdgConfigHome(env), 'cli-tools', 'blog.json')); + return paths; +} + +function asString(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function asLinks(value: unknown): BlogLink[] { + if (!Array.isArray(value)) return []; + return value.flatMap((entry): BlogLink[] => { + if (!entry || typeof entry !== 'object') return []; + const label = asString((entry as Record<string, unknown>).label); + const href = asString((entry as Record<string, unknown>).href); + if (!label || !href) return []; + const rel = asString((entry as Record<string, unknown>).rel); + return [rel ? { label, href, rel } : { label, href }]; + }); +} + +/** Coerce parsed JSON into a config, dropping anything malformed rather than trusting it. */ +export function normalizeConfig(raw: unknown): BlogConfig { + const object = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {}; + return { + siteTitle: asString(object.siteTitle), + author: asString(object.author), + links: asLinks(object.links), + disclosure: asString(object.disclosure), + trackerSiteId: asString(object.trackerSiteId), + adSlotId: asString(object.adSlotId), + adFormat: asString(object.adFormat) ?? EMPTY_CONFIG.adFormat, + }; +} + +/** Environment overrides, applied over whatever the file supplied. */ +export function applyEnv(config: BlogConfig, env: NodeJS.ProcessEnv = process.env): BlogConfig { + return { + ...config, + siteTitle: asString(env.BLOG_SITE_TITLE) ?? config.siteTitle, + author: asString(env.BLOG_AUTHOR) ?? config.author, + disclosure: asString(env.BLOG_DISCLOSURE) ?? config.disclosure, + trackerSiteId: asString(env.CRAWLPROOF_SITE_ID) ?? config.trackerSiteId, + adSlotId: asString(env.CRAWLPROOF_AD_SLOT) ?? config.adSlotId, + adFormat: asString(env.CRAWLPROOF_AD_FORMAT) ?? config.adFormat, + }; +} + +/** + * Read the first config that exists, then let the environment override it. + * + * A missing file is not an error — running with no config at all is a supported + * mode. Malformed JSON *is*, because silently publishing a post stripped of the + * author's identity is worse than refusing to publish one. + */ +export async function loadBlogConfig( + dir?: string, + env: NodeJS.ProcessEnv = process.env, +): Promise<BlogConfig> { + for (const path of configPaths(dir, env)) { + let text: string; + try { + text = await readFile(path, 'utf8'); + } catch { + continue; + } + try { + return applyEnv(normalizeConfig(JSON.parse(text)), env); + } catch (error) { + throw new Error(`${path}: not valid JSON — ${(error as Error).message}`); + } + } + return applyEnv(EMPTY_CONFIG, env); +} diff --git a/src/blog.ts b/src/blog.ts index 381ce26..eea8803 100644 --- a/src/blog.ts +++ b/src/blog.ts @@ -2,6 +2,8 @@ import { readdir, readFile, writeFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import { join } from 'node:path'; +import { type BlogConfig, type BlogLink, EMPTY_CONFIG } from './blog-config.ts'; + /** * The plain-HTML blog at ~/public_html/blog. * @@ -103,24 +105,52 @@ export function nextNumber(posts: readonly Pick<Post, 'n'>[]): string { * forbid scripts served from another host. Nothing on the page depends on it — * the post reads identically with JavaScript off — so the "usable without * JavaScript" half of the rule still holds. + * + * With no site id configured nothing is emitted at all, which is the fully + * valid case — and the default, so a fresh checkout never meters somebody + * else's traffic into an account it inherited from the repository. */ -export const TRACKER = - '<script data-site="099436d8-e1b1-4b4e-bc04-b3fbff5c4ead" src="https://crawlproof.com/stats.js" async></script>'; +export function tracker(siteId: string | null): string { + if (!siteId) return ''; + return `<script data-site="${esc(siteId)}" src="https://crawlproof.com/stats.js" async></script>`; +} /** * The sponsored bar that runs at the foot of every page. * - * `text_link` on purpose, not a 728x90 or 300x250: it is a 40px full-width + * `text_link` by default, not a 728x90 or 300x250: it is a 40px full-width * strip that carries its own "Sponsored" mark inside the frame, so `ad.js` * prepends no extra caption, and an unsold or blocked slot collapses to * nothing instead of leaving a banner-shaped hole. + * + * The slot id is the author's own, so it is configuration rather than a + * constant: a shared one would bill every installation's impressions to + * whoever happened to be in the file. `ad.js` loads only when there is a slot + * for it to fill, and the tracker is emitted here so no page carries it twice. */ -export const AD_UNIT = [ - '<aside data-cp-ad data-slot="50ba73a3-22b6-4264-9b2d-7f866759e287" data-format="text_link"></aside>', - '', - TRACKER, - '<script src="https://crawlproof.com/ad.js" async></script>', -].join('\n'); +export function adUnit( + config: Pick<BlogConfig, 'adSlotId' | 'adFormat' | 'trackerSiteId'>, +): string { + const tag = tracker(config.trackerSiteId); + if (!config.adSlotId) return tag; + + const slot = + `<aside data-cp-ad data-slot="${esc(config.adSlotId)}"` + + ` data-format="${esc(config.adFormat)}"></aside>`; + + return [slot, '', tag, '<script src="https://crawlproof.com/ad.js" async></script>'] + .filter((line, index, all) => line !== '' || all[index + 1] !== '') + .join('\n'); +} + +/** The footer identity links, or nothing when none are configured. */ +function identity(links: readonly BlogLink[]): string { + if (links.length === 0) return ''; + const anchors = links.map( + (link) => `<a rel="${esc(link.rel ?? 'me')}" href="${esc(link.href)}">${esc(link.label)}</a>`, + ); + return `\n<p>Find me: ${anchors.join(' ·\n')}</p>\n`; +} /** * Render a post file. @@ -128,21 +158,43 @@ export const AD_UNIT = [ * Deliberately smolweb-valid, which is stricter than "valid HTML": an explicit * `<html lang>`, `<head>` and `<body>`; `<meta http-equiv="Content-Type">` * rather than a bare `<meta charset>`, because every `<meta>` needs a `content` - * attribute; and every `<p>` closed. The one exception is {@link TRACKER}, the - * external analytics tag, which smolweb's no-third-party-script rule forbids. + * attribute; and every `<p>` closed. The one exception is {@link tracker}, the + * external analytics tag, which smolweb's no-third-party-script rule forbids — + * and which is absent unless a site id is configured. + * + * Everything identifying the author comes from {@link BlogConfig}. Rendered + * with the default config the post carries no byline, no identity links and no + * third-party scripts, so a checkout cannot publish somebody else's name or + * meter traffic into an account it inherited from the repository. */ -export function renderPost({ title, description, date, body = '' }: NewPost): string { +export function renderPost( + { title, description, date, body = '' }: NewPost, + config: BlogConfig = EMPTY_CONFIG, +): string { const day = date.slice(0, 10); const heading = typogrify(title); const content = body.trim() || '<h2>Start here</h2>\n\n<p>…</p>'; + // esc rather than typogrify: the site name is emitted identically in the + // <title> and in the feed link's title attribute, and an attribute is the + // stricter of the two. The byline is typogrified because a byline is prose. + const site = config.siteTitle ? ` — ${esc(config.siteTitle)}` : ''; + const feedTitle = config.siteTitle ? ` title="${esc(config.siteTitle)}"` : ''; + const byline = config.author + ? `<p><em>${day}, by ${typogrify(config.author)}.</em></p>` + : `<p><em>${day}</em></p>`; + const disclosure = config.disclosure + ? `\n\n<p><small>${typogrify(config.disclosure)}</small></p>` + : ''; + const footer = adUnit(config); + return `<!doctype html> <html lang="en"> <head> <meta http-equiv="Content-Type" content="text/html; charset=utf-8"> <meta name="viewport" content="width=device-width,initial-scale=1"> -<title>${heading} — Chovy's Blog - +${heading}${site} + @@ -152,10 +204,7 @@ export function renderPost({ title, description, date, body = '' }: NewPost): st

${heading}

-

${day}, by Anthony “chovy” Ettinger.

- -

How this was written: drafted with an AI assistant from my own notes, -then edited by me.

+${byline}${disclosure}