Releases: profullstack/cli-tools
Release list
v0.17.0 — hqtui
hqtui — every vital of a box, in the terminal
The HQTUI dashboard, wrapped so it is a command:
hqtui # this machine, live
hqtui --sim # the deterministic simulation
hqtui --screen traffic # open on a screen
hqtui --help # it is upstream's CLI: upstream's flagsTen screens. Beyond CPU, memory, disks and processes it reads what a system
monitor usually leaves alone: live sockets grouped by protocol, TCP/UDP/ICMP
counters with a retransmit rate, HTTP requests parsed out of nginx/apache
access logs, sshd authentication events, who is logged in, wtmp login history,
systemd units, containers, kernel counters and filesystem inode usage.
All of that unprivileged. sudo additionally exposes socket process names,
failed logins from btmp, access logs and the full journal — and sudo drops
your PATH:
sudo -E env "PATH=$PATH" hqtuiIt does not add temperatures. On a VM there are none to add: a KVM or Xen
guest is never shown the host's thermal hardware, and the dashboard says which
case it is rather than leaving the panel blank.
Two flags are ours, spelled --self-* because every plain word belongs to the
dashboard:
hqtui --self-update # reinstall the latest release
hqtui --self-where # which copy runs, and from whereThe first run installs it into ~/.local/share/cli-tools/vendor/hqtui rather
than globally. Both @profullstack/hqtui-demo (hqtui-demo) and the library
@profullstack/hqtui (hqtui, the same name as this wrapper) ship an
executable that would otherwise collide on PATH. Installed rather than
npx-ed because the box you are SSHing into because something is wrong is the
worst moment to need the registry before you can look at it.
Also in this release
root-ubuntu.sh installs the full hardware sensor and inventory set by
default — lm-sensors, i2c-tools, smartmontools, nvme-cli, ipmitool, acpi,
powertop, sysstat, dmidecode, lshw, pciutils, usbutils — and runs
sensors-detect on bare metal only, since on a guest it probes a machine with
no sensor chips and leaves an empty config that reads like a failure.
Upgrading
cli-tools update
# or
curl -fsSL https://raw.githubusercontent.com/profullstack/cli-tools/master/install.sh | shv0.16.0 — sysupdate
One new command, and a provisioner that stops leaving boxes with no slack.
sysupdate
Updating a machine was three commands nobody could quite remember the order of. Now it is one, and the pit gets it as /update.
sysupdate # apt update, apt upgrade, snap refresh
sysupdate --yes # ...without stopping to ask
sysupdate --no-snap # apt only
sysupdate --dry-run # print the commands, run nothingIt runs the three steps in order and stops at the first that fails, which is the && the one-liner had: there is no point upgrading against package lists that failed to refresh.
Three decisions worth knowing about:
apt, notapt-get. They are not the same command —apt upgradeinstalls a package that needs a new dependency,apt-get upgradeholds it back. That difference is how kernels and security updates quietly never land on a box everybody believes is current.sudoonly when not already root. A minimal image may have no sudo on it at all, and asking for it there fails for a reason that has nothing to do with updating anything.- A box with no snapd skips that step and says so, rather than reporting a failure for something that was never going to run. A box with no
aptis refused outright rather than left two thirds updated.
When the upgrade lands a kernel or a libc it names the packages and says a reboot is required — that being exactly the moment people stop thinking about it.
It is called sysupdate and not update because cli-tools update already means "move this checkout to the current commit". One word cannot usefully mean both that and "upgrade the operating system". The pit alias is /update, which is safe because nothing on PATH answers to that name.
root-ubuntu.sh: 2G of swap on a box that has none
A box with no swap has no slack: the kernel's only answer to a memory spike is the OOM killer, and what it picks is whatever was biggest — the build, the language server, the editor someone was working in.
The step only acts where there is no swap whatsoever, so a machine with a swap partition or zram is left exactly as it is rather than gaining a second swapfile nobody remembers making. It declines where a plain swapfile is wrong or dangerous (btrfs needs chattr +C and no compression; a swapfile on zfs can deadlock the box under exactly the pressure it was added to survive), inside a container where the kernel and its swap belong to the host, and when the disk cannot spare the space. vm.swappiness goes to 10 alongside — the default of 60 pages out memory that is still in use, which is how swap earned its reputation. SWAP_SIZE=0 turns it off.
Also
- one ssh-agent per user, under systemd
- the hardware inventory set installed by default: lm-sensors, smartmontools, nvme-cli, ipmitool, acpi, powertop, with sensor detection on bare metal
companions: diskpush, and installers that are not npmporkbun: Porkbun's own pre-flight, structured refusals kept structured, and the fact that registration spends prepaid credit rather than a card
Full changelog: v0.15.0...v0.16.0
v0.14.0 — torrent
torrent — make a torrent out of a directory, and get it seeded
torrent create ./album # writes album.torrent, prints the magnet
torrent seed ./album # …and hands the magnet to torlnk
torrent magnet album.torrent # for one you already have
torrent info album.torrentcreate-torrent writes a .torrent and never prints a hash; torlnk takes a magnet rather than a file. The two do not meet without something in between, and this is it.
The info hash is computed here, not by adding a dependency — a SHA-1 over the bencoded info dictionary, read verbatim. Decode and re-encode and the hash changes the moment a client wrote a key in an order we did not reproduce, which yields a well-formed magnet for a torrent that does not exist. A test pins our hash against the one a real client computed for a torrent it made itself.
Trackers were checked, not copied. A browser can only ever be a WebRTC peer, so a torrent with no wss:// tracker is invisible to every web player — on the DHT, found by desktop clients, and showing the browser a torrent with no peers, which reads as dead rather than as a missing tracker. Of the announce list the WebTorrent tooling ships by default:
| tracker | state |
|---|---|
tracker.leechers-paradise.org |
no DNS at all |
tracker.coppersurfer.tk |
UDP connect times out |
tracker.empire-js.us |
UDP connect times out |
tracker.btorrent.xyz |
self-signed cert; a browser refuses it |
What ships is two WSS trackers that complete a WebSocket handshake and four UDP trackers that return a connection id.
Other notes
--webseedembeds HTTP URLs already serving the same bytes (BEP 19), so the torrent is downloadable before any peer has it. The URL must serve the exact bytes, or it fails its hash check in a way that looks like corruption.--privateis opt-in and named: a private torrent is excluded from the DHT by every client honouring the flag — the opposite of the reason to make one here.- Seed duration is deliberately not a flag; torlnk's
--seed-timeis a daemon setting, not per-torrent.
Requirements
npm i -g create-torrent for create. torrent seed also needs torlnk running — and note torlnk installs with pnpm only (pnpm add -g torlnk); npm cannot install it at all.
Upgrading
cli-tools update — the install is symlinks into a working tree, so this pulls and relinks.
v0.13.0 — dl
dl — download a video, or just its audio, through yt-dlp
A thin front for yt-dlp, in the same shape vid is a thin front for ffmpeg.
dl https://example.com/watch?v=abc # the video
dl --height 720 https://example.com/v/abc # capped
dl audio https://example.com/watch?v=abc # → .m4a
dl info https://example.com/watch?v=abc # nothing downloaded
dl formats https://example.com/watch?v=abcTwo defaults are decisions rather than plumbing:
--no-playlist. A YouTube link copied while a mix is playing carrieslist=, and yt-dlp reads that as "download all of it" — the difference between one file and two hundred, on a command whose entire input is a pasted URL.- ffmpeg-awareness in the format selector. Above ~720p picture and sound arrive as separate streams needing a mux, so without ffmpeg the
bv*+baalternatives are a wasted download: yt-dlp fetches both halves and only then finds it cannot merge. Without ffmpegdlrestricts itself to single-stream formats and says so.dl audiois-x, which is ffmpeg, so there it is a hard requirement.
findBinary grew a probe flag: it ran candidates with -version, which ImageMagick and ffmpeg answer 0 — but yt-dlp's parser reads that single dash as seven combined short options and exits non-zero, so probing it the same way reported an installed binary as missing.
Installing what it needs
moshcode v0.66+ can install the binaries: moshcode install yt-dlp, moshcode install ffmpeg, moshcode install imagemagick.
Upgrading
cli-tools update — the install is symlinks into a working tree, so this pulls and relinks rather than re-cloning.
Note: v0.12.0 was never tagged; its work (companions) is in this history.
v0.11.0 — DEFAULT_GROUPS
Fixed
DEFAULT_GROUPS is documented in server.conf.example, but root-ubuntu.sh declared it as an unconditional assignment (DEFAULT_GROUPS="sudo,admin") instead of the "${KEY:-default}" form every other setting uses. read_server_config runs ~200 lines earlier, so the configured value — and any value passed in the environment, which the file header promises always wins — was read correctly and then silently discarded.
This was not cosmetic. The group prompt is skipped when there is no tty, so an unattended run (--refresh, or the documented curl … | bash) takes $DEFAULT_GROUPS verbatim for every account it creates. A box that had configured www-data,users,docker still put new accounts in sudo,admin — root-equivalent, and the opposite of what was asked for — with nothing in the run output looking wrong.
DEFAULT_GROUPS was the only affected key; the other fifteen server.conf.example documents were already overridable.
Added
Tests covering the class rather than the single key: every documented key must survive its own declaration, plus a behavioural check that reads a config file and then runs the real declaration line out of the script. Both fail against the old line.
DEFAULT_GROUPS is now documented in the script header and the README, since an unattended run never prompts and this is what silently decides how privileged new accounts are.
Upgrading: re-fetch the script — curl -fsSLO https://raw.githubusercontent.com/profullstack/cli-tools/master/root-ubuntu.sh. If you set DEFAULT_GROUPS before this release, check where existing accounts actually landed: ./root-ubuntu.sh groups.
v0.10.0 — shorten
One new command, and the first tagged release since v0.3.0.
shorten
shorten https://pit.moshcode.sh/n/blue.eggs/the-post-i-wrote-on-tuesday
# → https://pit.moshcode.sh/f/k7mq2xdMints a short link on the Moshpit registry and prints it. /f/<code> answers a 302 to wherever it points, from anywhere — no resolver, no extension, nothing installed on the other end. shorten list shows yours with hit counts, shorten rm <code> takes one down, and --name blue.eggs files a link under a Moshpit name you hold.
Nothing to configure on a machine where the pit works. The token moshcode login already wrote is read from ~/.moshcode/credentials.json. A new moshcode key in the credential store (cli-tools config set moshcode) and MOSHCODE_API_KEY cover a box that has the key but not moshcode.
It is a client, not a second shortener. The registry owns the codes and the rules about what may be redirected to — http(s) only, and a short link may not point at itself — and its refusals are passed through verbatim rather than reimplemented here, where the two copies would drift apart the first time one of them was tightened. Minting is authenticated for the same reason it is on the other side: an anonymous shortener is an open redirector with a database attached. It is idempotent per account, so the same url twice returns the same code and a retry cannot split one destination's hits in half.
The short url is the only thing on stdout, so shorten … --bare | pbcopy gets a url and nothing else.
The pit has the same thing as /shorten, shipped in moshcode v0.69.0.
What else has landed since v0.3.0
Tagging stopped for a while — the install is a curl … | sh one-liner that pulls master, so a release was never the distribution channel here and nothing waited on one. Six versions went by in the meantime:
porkbun— read and change DNS at Porkbun, and un-park a domain (0.9.0)favicon— every icon a site links, rendered from one SVG (0.8.0)root-ubuntu.sh— provision an Ubuntu/Debian server from one filecodeburn— where your AI spend goes, by task, tool, model and project (0.6.0)imgandvid— sharp/ImageMagick and ffmpeg, wrapped (0.5.0)genrewatch— what is coming out, and whether it exists at all (0.4.0)ask-webandtts— Perplexity and ElevenLabsaffiliate— a cursor over a list of signup pagescli-tools config— a 0600 credential store, andconfig pullfrom the team vaultcli-tools autoupdate— a user systemd timer that keeps the checkout current
Installing
curl -fsSL https://raw.githubusercontent.com/profullstack/cli-tools/master/install.sh | shUnchanged: the installer adopts an existing checkout rather than cloning a second copy, and cli-tools update pulls and relinks. This tag is a marker in the history, not a separate artifact to download.
Suite is 420 tests.
v0.3.0 — generate-names
Completes the naming pipeline: describe a product in a sentence, get a thousand candidates, keep only the ones you can register.
generate-names "a registry that checks whether Lean proofs actually compile" | domainfreeNew in 0.3.0
generate-names
Turns a sentence into candidate names, ready to pipe into domainfree.
generate-names "a tool that finds dead states in agent graphs" -n 1000 --tld dev
generate-names "an open directory of independent blogs" --words 1It asks the model for vocabulary, not for a thousand names. One cheap call returns ~40 head words and ~40 modifiers; the cross product is expanded locally and shuffled. Asking a model for 1,000 names directly repeats itself within a few hundred, drifts off the brief, and costs far more for a worse list — this way the call count is identical whether you want 10 names or 10,000.
Uses whichever of OPENAI_API_KEY / ANTHROPIC_API_KEY is set, OpenAI first when both are. Cheap tier by default on each side (gpt-4.1-mini / claude-haiku-4-5), overridable with --model. Called over plain fetch — this repo still has zero runtime dependencies.
Defaults to two English words and .com; --tld, --words, --count and --seed change that.
/domain:names
The generator as a moshcode command, alongside /domain:free and /domain:lookup.
moshcode plugin marketplace add profullstack/cli-tools
moshcode plugin install domain@cli-toolsFixed
domainfree --help used generate-names as a placeholder for "whatever produces your list", which read as a command that existed and got command not found. It exists now, and the help says so honestly.
Verified against the live API
1,000 names generated (all unique, all .com); the documented pipeline end to end returned 9 registerable domains from 40 candidates; suite 137/137; typecheck clean.
Installation note
~/.local/bin/* may still point at ~/scripts/bin for the older commands — install-links.mjs never clobbers a link it does not own. Use --force to switch them over.
v0.2.0 — domainfree
First tagged release of @profullstack/cli-tools.
The package is private: true and not on npm, so this is a tag and a set of notes rather than a publish. Install by cloning and running pnpm install && pnpm link:bin.
New in 0.2.0
domainfree — find the domains you can actually register
domainfree sorrycheck.com sinkstate.com
domainfree --file candidates.txt
generate-names | domainfree --jobs 24Only registerable names go to stdout, one per line, so it pipes into anything. Roughly 8,500 names in 45 seconds at the default concurrency.
Availability is read from RDAP, never inferred from DNS, because DNS cannot tell registration apart from configuration: a parked domain resolves and is taken, and a domain registered with no nameservers returns NXDOMAIN exactly like a name nobody owns.
Over 8,513 generated candidates, the DNS shortcut (dig NAME | grep "ANSWER: 0") reported 20 registered domains as free and missed none that were genuinely free. oubliette.com is the case to remember: registered in 1996, paid through 2034, three nameservers, no A record.
An indeterminate response — 429, 5xx, timeout — is retried once and then reported as ERR:<code>, never as available, and the exit status is 2. A name wrongly reported free is the only failure here that costs real time.
domain plugin
Registered in the marketplace alongside blog:
moshcode plugin marketplace add profullstack/cli-tools
moshcode plugin install domain@cli-tools/domain:free— filter a list of candidates down to registerable names/domain:lookup— everything about one name (RDAP record, dates, nameservers, DNS, reverse PTR) as JSON, wrapping the existingdomainjson, which previously had no plugin
Also in this repo as of 0.2.0
| Command | Does |
|---|---|
blog-post |
Publish to the plain-HTML blog: next post number, valid template, index entry, feed rebuild, and a lint that catches what silently breaks RSS |
domainjson |
Whois-style, JSON-first lookup of a single name |
domainfree |
Bulk availability across thousands of names |
gh-prs |
Open pull requests across orgs and users, as an aligned table |
gh-prs-merge |
Squash-merge every PR that qualifies; dry run by default |
gh-prs-fix-all |
Repair PRs that need it |
tcfeed |
Read a subreddit, scan the repos its posts link, shortlist what is worth reading |
Installation note
install-links.mjs will report every command as "points elsewhere" if ~/.local/bin/* still points at ~/scripts/bin, which is the current state on the dev box. That is deliberate — the script never clobbers a link it does not own. Use --force to switch them over to this repo.