Tested 2026-11-15. Disclosure: I'm on the UnAIMyText team, so keep in mind that I'm the one who put it at #3 in the tool list. The linter is free to copy.

Our internal deploy tool grew from four flags to nineteen over a year. Nobody wanted to write help text for the new ones, so a teammate asked an open model on DeepInfra to fill them in. It did. Then someone ran shipit --help during an incident and got this as the description for --dry-run: "This flag allows you to seamlessly preview changes with ease!"

That isn't wrong, exactly. It's just forty words longer than "Print the plan without writing files," and nobody reading help text at 2 a.m. wants an exclamation mark.

So I wrote a small linter. Point it at help text and it flags the usual model habits plus the formatting problems that make --help hard to scan. Give it --draft with a flag name and a note, and it asks DeepInfra for a description, then lints that before anyone pastes it into the code.

The DeepInfra part, briefly

DeepInfra's OpenAI-compatible API docs say the only changes from OpenAI code are the base URL (https://api.deepinfra.com/v1/openai), your DeepInfra token and a model from their catalog. So it's a plain POST to /chat/completions with Authorization: Bearer $DEEPINFRA_API_KEY and a messages array. The docs' examples use deepseek-ai/DeepSeek-V4-Flash-0731, which is the default here, and DEEPINFRA_MODEL swaps it. The generated text comes back in choices[0].message.content.

I tried the live endpoint with a fake key and got a 401 with "User is not authorized to access this resource". Without the header it's also 401, with "missing API key". Both exit 2.

Bun runs TypeScript directly and ships fetch, Bun.file, Bun.stdin and Bun.spawnSync, so the whole thing is one file with no package.json.

What it checks

  • No Usage: line
  • Any line over 80 columns
  • Descriptions that open with "This flag", "Allows you to", "Enables the ability to" or "Use this to"
  • Filler: seamless, effortless, robust, powerful, leverage, comprehensive, streamline, cutting-edge, with ease, ensures that (in descriptions and in the prose around them)
  • Exclamation marks and emoji
  • TODO, TBD, FIXME and [description] leftovers
  • A flag with no description, or the same long flag listed twice
  • Trailing periods used on some descriptions but not others

Exit 0 means clean, 1 means findings, 2 means bad arguments, an unreadable file, a command that printed nothing, a network failure or an API error.

The linter

// helplint.ts checks CLI --help text for filler and inconsistent option descriptions, // or asks a DeepInfra-hosted model to draft one flag's description and checks the draft. // // Usage: // bun helplint.ts help.txt lint saved help text // mytool --help | bun helplint.ts - read from stdin // bun helplint.ts --cmd "mytool --help" run the command, lint its output // bun helplint.ts --draft --dry-run "notes..." draft with DeepInfra, then lint // add --json for machine-readable output // // Exit: 0 clean, 1 lint findings, 2 usage/network/API errors const MAX_COLS = 80; const FILLER = /\b(seamless(ly)?|effortless(ly)?|robust|powerful|leverag(e|es|ing)|comprehensive|streamlin(e|ed|es|ing)|cutting-edge|with ease|ensures? that)\b/gi; const OPENER = /^(this (flag|option|switch)|allows (you|users) to|enables the ability to|use this to)\b/i; const EMOJI = /\p{Extended_Pictographic}/u; const PLACEHOLDER = /\b(TODO|TBD|FIXME)\b|\[(description|insert[^\]]*)\]/i; const OPTION = /^\s{1,8}((?:-[A-Za-z0-9],?\s*)?--?[A-Za-z0-9][\w-]*(?:[ =][<\[]?[\w.|-]+[>\]]?)?)(?:\s{2,}(\S.*))?$/; type Finding = { line: number; issue: string }; type Option = { line: number; flag: string; desc: string }; function parse(text: string): { options: Option[]; hasUsage: boolean; lines: string[] } { const lines = text.replace(/\r\n/g, "\n").split("\n"); const options: Option[] = []; for (let i = 0; i < lines.length; i++) { const m = lines[i].match(OPTION); if (m) options.push({ line: i + 1, flag: m[1].trim(), desc: (m[2] ?? "").trim() }); } return { options, hasUsage: lines.some((l) => /^\s*usage:/i.test(l)), lines }; } function lintDesc(o: Option): Finding[] { const out: Finding[] = []; const add = (issue: string) => out.push({ line: o.line, issue: `${o.flag}: ${issue}` }); if (!o.desc) return [{ line: o.line, issue: `${o.flag}: no description` }]; if (OPENER.test(o.desc)) add(`opens with "${o.desc.match(OPENER)![0]}"; start with the verb`); const fill = o.desc.match(FILLER); if (fill) add(`filler: ${[...new Set(fill.map((f) => f.toLowerCase()))].join(", ")}`); if (o.desc.includes("!")) add("exclamation mark"); if (EMOJI.test(o.desc)) add("emoji"); if (PLACEHOLDER.test(o.desc)) add("placeholder left in"); return out; } function lint(text: string): Finding[] { const { options, hasUsage, lines } = parse(text); const out: Finding[] = []; if (!hasUsage) out.push({ line: 1, issue: 'no "Usage:" line' }); if (options.length === 0) out.push({ line: 1, issue: "no options found; is this --help output?" }); lines.forEach((l, i) => { if (l.length > MAX_COLS) out.push({ line: i + 1, issue: `line is ${l.length} columns (cap ${MAX_COLS})` }); }); const optLines = new Set(options.map((o) => o.line)); lines.forEach((l, i) => { if (optLines.has(i + 1) || !l.trim()) return; const fill = l.match(FILLER); if (fill) out.push({ line: i + 1, issue: `filler: ${[...new Set(fill.map((f) => f.toLowerCase()))].join(", ")}` }); if (l.includes("!")) out.push({ line: i + 1, issue: "exclamation mark" }); }); const seen = new Map<string, number>(); for (const o of options) { const long = o.flag.match(/--[\w-]+/)?.[0] ?? o.flag; if (seen.has(long)) out.push({ line: o.line, issue: `${long} also listed on line ${seen.get(long)}` }); else seen.set(long, o.line); out.push(...lintDesc(o)); } const described = options.filter((o) => o.desc); const dotted = described.filter((o) => o.desc.endsWith(".")).length; if (dotted > 0 && dotted < described.length) out.push({ line: 1, issue: `trailing periods are mixed (${dotted} of ${described.length} descriptions)` }); return out.sort((a, b) => a.line - b.line); } function stripFence(s: string): string { const m = s.trim().match(/^``` [a-z]*\s*([\s\S]*?)\s* ```$/); return m ? m[1] : s.trim(); } async function draft(flag: string, notes: string): Promise<string> { const key = process.env.DEEPINFRA_API_KEY; if (!key) throw new Error("DEEPINFRA_API_KEY is not set"); const base = process.env.DEEPINFRA_BASE_URL ?? "https://api.deepinfra.com/v1/openai"; const model = process.env.DEEPINFRA_MODEL ?? "deepseek-ai/DeepSeek-V4-Flash-0731"; const res = await fetch(`${base}/chat/completions`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` }, signal: AbortSignal.timeout(30_000), body: JSON.stringify({ model, temperature: 0.3, messages: [ { role: "system", content: 'You write CLI --help text. Reply with JSON only: {"description": "..."}. One line, under 50 characters, starts with a verb, no period, no marketing words.' }, { role: "user", content: `Flag: ${flag}\nWhat it does: ${notes}` }, ], }), }); const body: any = await res.json().catch(() => ({})); if (!res.ok) throw new Error(`DeepInfra ${res.status}: ${body?.error?.message ?? "no error message"}`); const content = body?.choices?.[0]?.message?.content ?? ""; if (!content.trim()) throw new Error("empty reply from model"); let desc: unknown; try { desc = JSON.parse(stripFence(content)).description; } catch { throw new Error(`reply was not the JSON I asked for: ${content.slice(0, 80)}`); } if (typeof desc !== "string" || !desc.trim()) throw new Error("reply had no description"); return desc.trim(); } function report(findings: Finding[], asJson: boolean): number { if (asJson) console.log(JSON.stringify(findings, null, 2)); else if (findings.length === 0) console.log("ok"); else for (const f of findings) console.log(`line ${f.line}: ${f.issue}`); return findings.length ? 1 : 0; } async function main(argv: string[]): Promise<number> { const asJson = argv.includes("--json"); const args = argv.filter((a) => a !== "--json"); try { if (args[0] === "--draft") { if (args.length !== 3 || !args[1].startsWith("-")) { console.error('usage: bun helplint.ts --draft --flag-name "what it does"'); return 2; } const desc = await draft(args[1], args[2]); const line = ` ${args[1].padEnd(22)} ${desc}`; console.error(line); return report(lintDesc({ line: 1, flag: args[1], desc }).concat( line.length > MAX_COLS ? [{ line: 1, issue: `line is ${line.length} columns (cap ${MAX_COLS})` }] : []), asJson); } let text: string; if (args[0] === "--cmd" && args[1]) { const p = Bun.spawnSync(["sh", "-c", args[1]], { stdout: "pipe", stderr: "pipe" }); text = p.stdout.toString() || p.stderr.toString(); if (!text.trim()) throw new Error(`"${args[1]}" printed nothing`); } else if (args.length === 1) { text = args[0] === "-" ? await Bun.stdin.text() : await Bun.file(args[0]).text(); } else { console.error("usage: bun helplint.ts <file | - | --cmd \"tool --help\" | --draft --flag \"notes\"> [--json]"); return 2; } return report(lint(text), asJson); } catch (e) { console.error(`error: ${(e as Error).message}`); return 2; } <...
Source:https://dev.to/unaimytext_app/humanize-deepinfra-output-with-bun-a-cli-help-text-linter-5-free-tools-2026-21pc