fix(cli): add help [cmd], suggest the closest command or flag, and quiet the nags (#6621)

* fix(cli): add help [cmd], suggest the closest command or flag, and quiet the nags

`hyperframes help` printed "Unknown command help" with raw ANSI codes and no
suggestion. It is now an alias: `help [cmd...]` is `[cmd...] --help`.

An unknown command (at the root or inside a group like `cloud`) prints
"Unknown command: rendr", "Did you mean 'hyperframes render'?" and the group's
usage, all on stderr, and exits 2. An unknown flag suggests the flag it most
likely means ("Did you mean --output?"). Usage text printed for a usage error
now goes to stderr instead of stdout, and citty's colors are stripped from
help whenever this CLI's own colors are off.

No update, skills or app-history notice is appended to a usage error. The
skills notice shows at most once a day per project (timestamps in
~/.hyperframes/skills-notice.json) and, in an unattended run, only when a core
skill is missing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(cli): reject an unknown top-level command without loading every command

citty looks for an alias on every registered command before it reports an
unknown name, which loads all of them. From a source checkout without the
studio packages built, that meant `hyperframes rendr` failed in preview's
build check with exit 1 instead of suggesting `render`. The name is now
checked against the registry first.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(cli): help with a mistyped subcommand reports it like the command itself

`hyperframes help cloud rendr` printed cloud's help and exited 0, while
`hyperframes cloud rendr` exits 2 with a suggestion. Both now exit 2 with the
suggestion. A group that also takes positional arguments (timeline takes a
project directory) still shows its help for a word it does not know.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
James Russo
2026-10-28 09:39:06 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 3d1c0764a0
commit e2c9151320
11 changed files with 311 additions and 42 deletions
+9 -1
View File
@@ -8,7 +8,9 @@ This page lists every command and every flag. If you are still deciding which
command you need, the [CLI guide](/developers/cli) is the shorter route.
The installed version is always the authority. Run
`npx hyperframes <command> --help` to see exactly what your copy accepts.
`npx hyperframes help <command>` (or `npx hyperframes <command> --help`) to see
exactly what your copy accepts. A mistyped command or flag exits 2 and suggests
the closest one: `Did you mean 'hyperframes render'?`.
```bash
# No install needed
@@ -96,6 +98,12 @@ there. While an install is underway, the notice is not shown.
`HYPERFRAMES_NO_UPDATE_CHECK=1` turns off the check, the install, and the
notice; `HYPERFRAMES_NO_AUTO_INSTALL=1` turns off only the install.
**Skills notice.** When installed HyperFrames skills are out of date or missing,
a command ends with one line on stderr naming `npx hyperframes skills update`.
It shows at most once a day per project. In an unattended run it shows only when
a core skill is missing. `HYPERFRAMES_SKIP_SKILLS=1` turns it off. No notice
follows a usage error.
## Create a project
### `init`
+42 -2
View File
@@ -70,7 +70,7 @@ interface Run {
stderr: string;
}
function runAsAgent(args: string[]): Promise<Run> {
function runAsAgent(args: string[], { ci = true } = {}): Promise<Run> {
return new Promise((resolve, reject) => {
const child = spawn("bun", ["run", cliEntry, ...args], {
cwd: home,
@@ -80,7 +80,7 @@ function runAsAgent(args: string[]): Promise<Run> {
PATH: process.env.PATH,
HOME: home,
USERPROFILE: home,
CI: "1",
...(ci ? { CI: "1" } : {}),
CLAUDECODE: "1",
},
});
@@ -153,6 +153,46 @@ describe("agent-mode contract", () => {
expect(run.code, run.stderr).toBe(2);
});
it.each([
["help", "Usage:"],
["help render", "USAGE hyperframes render"],
["help cloud render", "USAGE cloud render"],
])("`%s` prints that help on stdout and exits 0", async (args, heading) => {
const run = await runAsAgent(args.split(" "));
expectQuiet(run);
expect(run.code, run.stderr).toBe(0);
expect(run.stdout).toContain(heading);
expect(run.stderr).toBe("");
});
it("help stays plain inside an agent even without CI", async () => {
const run = await runAsAgent(["render", "--help"], { ci: false });
expectQuiet(run);
expect(run.code).toBe(0);
});
it.each([
["rendr", "Did you mean 'hyperframes render'?"],
["cloud rendr", "Did you mean 'hyperframes cloud render'?"],
["help rendr", "Did you mean 'hyperframes render'?"],
["help cloud rendr", "Did you mean 'hyperframes cloud render'?"],
])(
"unknown command `%s` suggests the closest one, usage on stderr, exit 2",
async (args, hint) => {
const run = await runAsAgent(args.split(" "));
expectQuiet(run);
expect(run.code).toBe(2);
expect(run.stdout).toBe("");
expect(run.stderr).toContain(hint);
},
);
it("an unknown flag suggests the flag it most likely means", async () => {
const run = await runAsAgent(["render", "--out", "x.mp4"]);
expect(run.code).toBe(2);
expect(run.stderr).toContain("Did you mean --output?");
});
it.each([["publish"], ["upgrade"]])(
"%s without --yes refuses to prompt, names --yes, and exits 2",
async (name) => {
+73 -15
View File
@@ -50,6 +50,10 @@ import { existsSync } from "node:fs";
import { VERSION } from "./version.js";
let argv = process.argv.slice(2);
if (argv[0] === "help") {
process.argv = [...process.argv.slice(0, 2), ...argv.slice(1), "--help"];
argv = process.argv.slice(2);
}
const commandArg = argv[0];
const rootVersionRequested =
commandArg === "--version" ||
@@ -89,7 +93,8 @@ import type { ArgsDef, CommandDef } from "citty";
import { getRunId } from "./telemetry/runId.js";
import { reportCommandFailure, trackCommandFailures } from "./utils/command-failure-tracking.js";
import { isRenderSucceeded } from "./utils/render-success-state.js";
import { resolveCommandUsage } from "./utils/commandUsageResolution.js";
import { resolveCommandUsage, type ResolvedCommandUsage } from "./utils/commandUsageResolution.js";
import { closestMatch } from "./utils/didYouMean.js";
import { isDevMode } from "./utils/env.js";
import {
CliResultSignal,
@@ -347,13 +352,17 @@ async function finalizeCli(result: CommandResult): Promise<void> {
runId,
});
// No network wait: the exit handler's flushSync() delivers what is still queued.
if (result.kind !== "usage_error") printNotices();
process.exitCode = exitCode;
}
function printNotices(): void {
if (!hasJsonFlag) {
_printUpdateNotice?.();
_printStalePinNotice?.();
}
_printSkillsUpdateNotice?.();
if (_appHistoryNotice) process.stderr.write(`◇ ${_appHistoryNotice}\n`);
process.exitCode = exitCode;
}
registerRootExitRequester((exitCode) => {
@@ -512,26 +521,69 @@ process.on("unhandledRejection", (reason) => {
// Lazy-load help renderer — avoids allocating help data on non-help invocations
async function showUsage<T extends ArgsDef>(
cmd: CommandDef<T>,
parent?: CommandDef<T>,
parent: CommandDef<T> | undefined,
print: (text: string) => void,
): Promise<void> {
const { showUsage: impl } = await import("./help.js");
return impl(cmd as CommandDef, parent as CommandDef | undefined);
return impl(cmd as CommandDef, parent as CommandDef | undefined, print);
}
async function showRequestedUsage(): Promise<void> {
// Prints the suggestion and the group's usage on stderr; the caller throws the returned error.
async function unknownCommandError(requested: ResolvedCommandUsage): Promise<CliUsageError> {
const word = requested.unknownSubcommand ?? "";
const group = ["hyperframes", ...requested.path].join(" ");
const declared = requested.command.subCommands;
const subCommands = typeof declared === "function" ? await declared() : await declared;
const match = closestMatch(word, Object.keys(subCommands ?? {}));
console.error(`Unknown command: ${word}`);
if (match) console.error(`Did you mean '${group} ${match}'?`);
console.error("");
await showUsage(requested.command, requested.parent, console.error);
return new CliUsageError(`Unknown command: ${word}`, { presented: true });
}
// A group like `timeline` also takes a project directory, so a word it does not know may be one.
function takesPositionals(command: CommandDef): boolean {
const args = command.args;
return (
typeof args === "object" &&
args !== null &&
Object.values(args).some((arg) => arg.type === "positional")
);
}
async function showRequestedUsage(print: (text: string) => void): Promise<void> {
const requested = await resolveCommandUsage(main as CommandDef, argv);
return showUsage(requested.command, requested.parent);
if (requested.unknownSubcommand && !takesPositionals(requested.command)) {
throw await unknownCommandError(requested);
}
return showUsage(requested.command, requested.parent, print);
}
// citty would load every command looking for an alias before it gives up on an unknown name.
async function rejectUnknownTopLevelCommand(): Promise<void> {
const word = argv[0];
if (!word || word.startsWith("-") || Object.hasOwn(subCommands, word)) return;
throw await unknownCommandError({
command: main as CommandDef,
path: [],
unknownSubcommand: word,
});
}
function isCittyError(error: unknown, code: string): boolean {
return (
error instanceof Error &&
error.name === "CLIError" &&
(error as { code?: unknown }).code === code
);
}
// citty's own parse errors (missing argument, bad enum value, unknown or missing subcommand).
const CITTY_USAGE_CODES = new Set(["EARG", "E_UNKNOWN_COMMAND", "E_NO_COMMAND"]);
function isCittyUsageError(error: unknown): boolean {
return (
error instanceof Error &&
error.name === "CLIError" &&
CITTY_USAGE_CODES.has(String((error as { code?: unknown }).code))
);
return [...CITTY_USAGE_CODES].some((code) => isCittyError(error, code));
}
function commandResultForError(error: unknown): CommandResult {
@@ -547,16 +599,22 @@ function commandResultForError(error: unknown): CommandResult {
async function executeCli(): Promise<void> {
let result: CommandResult = { exitCode: 0, kind: "success" };
try {
if (isHelp) await showRequestedUsage();
else await runCommand(main, { rawArgs: argv });
} catch (error) {
if (isHelp) await showRequestedUsage(console.log);
else {
await rejectUnknownTopLevelCommand();
await runCommand(main, { rawArgs: argv });
}
} catch (caught) {
const error = isCittyError(caught, "E_UNKNOWN_COMMAND")
? await unknownCommandError(await resolveCommandUsage(main as CommandDef, argv))
: caught;
result = commandResultForError(error);
if (!(error instanceof CliResultSignal)) {
commandFailed = true;
await reportCommandFailure(command, error);
const typed = error instanceof CliUsageError || error instanceof CliRuntimeError;
const usage = error instanceof CliUsageError && !error.result.presented;
if (usage && !error.hint) await showRequestedUsage();
if (usage && !error.hint) await showRequestedUsage(console.error);
if (!typed || !error.result.presented) {
console.error(error instanceof Error ? error.message : String(error));
}
+13 -6
View File
@@ -6,7 +6,8 @@
*/
import { renderUsage } from "citty";
import type { CommandDef } from "citty";
import { c } from "./ui/colors.js";
import { stripVTControlCharacters } from "node:util";
import { c, isColorSupported } from "./ui/colors.js";
import { VERSION } from "./version.js";
// ── Root-level command groups ──────────────────────────────────────────────
@@ -190,7 +191,9 @@ function renderRootHelp(): string {
}
lines.push("");
lines.push(`Run ${c.cyan("hyperframes <command> --help")} for more information about a command.`);
lines.push(
`Run ${c.cyan("hyperframes help <command>")} (or ${c.cyan("<command> --help")}) for more information about a command.`,
);
return lines.join("\n");
}
@@ -209,15 +212,19 @@ function formatExamples(examples: Example[]): string {
// ── Main showUsage override ────────────────────────────────────────────────
// fallow-ignore-next-line complexity
export async function showUsage(cmd: CommandDef, parent?: CommandDef): Promise<void> {
export async function showUsage(
cmd: CommandDef,
parent?: CommandDef,
print: (text: string) => void = console.log,
): Promise<void> {
if (!parent) {
console.log(renderRootHelp() + "\n");
print(renderRootHelp() + "\n");
return;
}
const meta = await (typeof cmd.meta === "function" ? cmd.meta() : cmd.meta);
const usage = await renderUsage(cmd, parent);
console.log(usage + "\n");
print((isColorSupported ? usage : stripVTControlCharacters(usage)) + "\n");
const name = meta?.name;
if (name) {
@@ -225,7 +232,7 @@ export async function showUsage(cmd: CommandDef, parent?: CommandDef): Promise<v
const parentName = parentMeta?.name;
const examples = STATIC_EXAMPLES[name] ?? (await loadExamples(name, parentName));
if (examples) {
console.log(formatExamples(examples) + "\n");
print(formatExamples(examples) + "\n");
}
}
}
@@ -36,3 +36,29 @@ describe("resolveCommandUsage", () => {
expect((await resolveCommandUsage(root, ["--help"])).command).toBe(root);
});
});
describe("resolveCommandUsage path and unknown words", () => {
const render = defineCommand({ meta: { name: "render" } });
const cloud = defineCommand({ meta: { name: "cloud" }, subCommands: { render } });
const root = defineCommand({ meta: { name: "hyperframes" }, subCommands: { cloud } });
it("returns the words that named the command", async () => {
expect((await resolveCommandUsage(root, ["cloud", "render", "x"])).path).toEqual([
"cloud",
"render",
]);
});
it("names the first word that is not a subcommand of a group", async () => {
const resolved = await resolveCommandUsage(root, ["cloud", "rendr"]);
expect(resolved.command).toBe(cloud);
expect(resolved.path).toEqual(["cloud"]);
expect(resolved.unknownSubcommand).toBe("rendr");
});
it("does not treat a leaf command's positional as an unknown command", async () => {
expect(
(await resolveCommandUsage(root, ["cloud", "render", "./dir"])).unknownSubcommand,
).toBeUndefined();
});
});
@@ -5,6 +5,8 @@ type LazyCommandDef = CommandDef | (() => CommandDef | Promise<CommandDef>);
export interface ResolvedCommandUsage {
command: CommandDef;
parent?: CommandDef;
path: string[];
unknownSubcommand?: string;
}
async function loadSubcommand(command: CommandDef, name: string): Promise<CommandDef | undefined> {
@@ -21,12 +23,20 @@ export async function resolveCommandUsage(
): Promise<ResolvedCommandUsage> {
let command = root;
let parent: CommandDef | undefined;
const path: string[] = [];
for (const argument of argv) {
if (argument.startsWith("-")) break;
const child = await loadSubcommand(command, argument);
if (!child) break;
if (!child)
return {
command,
parent,
path,
unknownSubcommand: command.subCommands ? argument : undefined,
};
parent = command;
command = child;
path.push(argument);
}
return { command, parent };
return { command, parent, path };
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { closestMatch } from "./didYouMean.js";
describe("closestMatch", () => {
const commands = ["render", "preview", "publish", "lint", "check", "help"];
it.each([
["rendr", "render"],
["previw", "preview"],
["pub", "publish"],
["chekc", "check"],
])("suggests %s -> %s", (input, expected) => {
expect(closestMatch(input, commands)).toBe(expected);
});
it("suggests nothing when no candidate is close", () => {
expect(closestMatch("zzzzzz", commands)).toBeUndefined();
expect(closestMatch("x", commands)).toBeUndefined();
});
it("completes an abbreviated flag", () => {
expect(closestMatch("out", ["output", "fps", "quality"])).toBe("output");
});
});
+30
View File
@@ -0,0 +1,30 @@
function editDistance(a: string, b: string): number {
let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
for (let i = 1; i <= a.length; i++) {
const current = [i];
for (let j = 1; j <= b.length; j++) {
const substitution = previous[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1);
current[j] = Math.min(previous[j]! + 1, current[j - 1]! + 1, substitution);
}
previous = current;
}
return previous[b.length]!;
}
/** The candidate `input` most likely misspells or abbreviates, or undefined when none is close. */
export function closestMatch(input: string, candidates: Iterable<string>): string | undefined {
const names = [...candidates];
const prefixed = input.length >= 2 ? names.find((name) => name.startsWith(input)) : undefined;
if (prefixed) return prefixed;
const limit = input.length <= 3 ? 1 : Math.max(2, Math.floor(input.length / 3));
let best: string | undefined;
let bestDistance = limit + 1;
for (const name of names) {
const distance = editDistance(input, name);
if (distance < bestDistance) {
best = name;
bestDistance = distance;
}
}
return best;
}
+10 -4
View File
@@ -1,6 +1,7 @@
import type { ArgDef, ArgsDef, CommandDef } from "citty";
import { CliUsageError } from "./commandResult.js";
import { c } from "../ui/colors.js";
import { closestMatch } from "./didYouMean.js";
// citty is permissive: an unrecognized flag (e.g. `render --out x` when the flag
// is `--output`/`-o`) is silently ignored instead of rejected, so the value is
@@ -65,6 +66,14 @@ function unknownFlagIn(tok: string, known: Set<string>): string | null {
return null;
}
function unknownFlagError(flag: string, known: Set<string>, path: string): CliUsageError {
const longNames = [...known].filter((name) => name.length > 1);
const match = flag.startsWith("--") ? closestMatch(flag.slice(2), longNames) : undefined;
const help = `Run 'hyperframes ${path ? `${path} ` : ""}--help' to see its flags.`;
const hint = match ? `Did you mean --${match}? ${help}` : help;
return new CliUsageError(`Unknown flag: ${flag}`, { hint });
}
/**
* Throw on the first flag in `rawArgs` not declared by `cmd` (its args + aliases
* + the global set). Only dash-prefixed tokens are inspected, so positionals and
@@ -81,10 +90,7 @@ export function assertKnownFlags(cmd: CommandDef<ArgsDef>, rawArgs: string[], pa
for (const tok of rawArgs) {
if (tok === "--") break;
const bad = unknownFlagIn(tok, known);
if (bad) {
const hint = `Run 'hyperframes ${path ? `${path} ` : ""}--help' to see its flags.`;
throw new CliUsageError(`Unknown flag: ${bad}`, { hint });
}
if (bad) throw unknownFlagError(bad, known, path);
}
}
@@ -1,5 +1,8 @@
// Nudge-count coverage: the printed total is outdated plus missing, as cached by refreshSkillsCache.
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
type FakeConfig = Record<string, unknown>;
@@ -9,6 +12,9 @@ let config: FakeConfig;
let cachedRead: FakeConfig | null = null;
vi.mock("../telemetry/config.js", () => ({
get CONFIG_PATH() {
return join(gate.stateDir, "config.json");
},
readConfig: () => ({ ...(cachedRead ?? config) }),
readConfigFresh: () => ({ ...config }),
writeConfig: (next: FakeConfig) => {
@@ -16,7 +22,14 @@ vi.mock("../telemetry/config.js", () => ({
},
}));
const gate = vi.hoisted(() => ({ disabled: false, noTty: false, answers: true }));
const gate = vi.hoisted(() => ({
disabled: false,
noTty: false,
answers: true,
unattended: false,
stateDir: "",
}));
vi.mock("./attendedTerminal.js", () => ({ isUnattended: () => gate.unattended }));
vi.mock("./updateCheck.js", () => ({
updateCheckDisabled: () => gate.disabled,
updateNoticesSuppressed: () => gate.disabled || gate.noTty,
@@ -37,6 +50,8 @@ describe("skillsUpdateCheck", () => {
gate.disabled = false;
gate.noTty = false;
gate.answers = true;
gate.unattended = false;
gate.stateDir = mkdtempSync(join(tmpdir(), "hf-skills-notice-"));
vi.stubEnv("HYPERFRAMES_SKIP_SKILLS", "");
});
afterEach(() => {
@@ -114,16 +129,18 @@ describe("skillsUpdateCheck", () => {
});
/** Drive printSkillsUpdateNotice from the given cache shape; returns what it wrote (if anything). */
async function noticeTextFor(cache: FakeConfig): Promise<string | null> {
async function noticeTextFor(cache: FakeConfig, project = "/projects/a"): Promise<string | null> {
config = cache;
const { printSkillsUpdateNotice } = await import("./skillsUpdateCheck.js");
const writeSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
printSkillsUpdateNotice();
printSkillsUpdateNotice(project);
const calls = [...writeSpy.mock.calls];
writeSpy.mockRestore();
if (writeSpy.mock.calls.length === 0) return null;
expect(writeSpy).toHaveBeenCalledTimes(1);
return String(writeSpy.mock.calls[0]?.[0]);
if (calls.length === 0) return null;
expect(calls).toHaveLength(1);
return String(calls[0]?.[0]);
}
it("suppresses a cached stale-skills notice in an attended plugin run", async () => {
@@ -138,6 +155,24 @@ describe("skillsUpdateCheck", () => {
expect(text).toContain("3 HyperFrames skills out of date or missing");
});
it("prints at most once a day per project", async () => {
const stale = { skillsOutdatedCount: 2, skillsMissingCount: 0 };
expect(await noticeTextFor(stale, "/projects/a")).toContain("2 HyperFrames skills");
expect(await noticeTextFor(stale, "/projects/a")).toBeNull();
expect(await noticeTextFor(stale, "/projects/b")).toContain("2 HyperFrames skills");
});
it("unattended, says nothing about skills that are only outdated", async () => {
gate.unattended = true;
expect(await noticeTextFor({ skillsOutdatedCount: 3, skillsMissingCount: 0 })).toBeNull();
});
it("unattended, still names missing skills", async () => {
gate.unattended = true;
const text = await noticeTextFor({ skillsOutdatedCount: 3, skillsMissingCount: 1 });
expect(text).toContain("1 HyperFrames skill out of date or missing");
});
it("prints nothing when the update check is disabled (CI, dev, opt-out)", async () => {
gate.disabled = true;
expect(await noticeTextFor({ skillsOutdatedCount: 2, skillsMissingCount: 1 })).toBeNull();
+31 -6
View File
@@ -6,7 +6,10 @@
// check on their own, but they DO run render/lint/validate — so we piggyback
// the reminder on the commands they already run.
import { readConfig, readConfigFresh, writeConfig } from "../telemetry/config.js";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { CONFIG_PATH, readConfig, readConfigFresh, writeConfig } from "../telemetry/config.js";
import { isUnattended } from "./attendedTerminal.js";
import { hostAnswers } from "./hostAnswers.js";
import { checkSkills } from "./skillsManifest.js";
import { updateCheckDisabled } from "./updateCheck.js";
@@ -99,6 +102,26 @@ export function skillsCheckDue(): boolean {
return !cacheFresh(readConfig().lastSkillsCheck, Date.now());
}
/** True at most once a day per project; records the showing. Unreadable state counts as never shown. */
function claimDailyNotice(project: string, now = Date.now()): boolean {
const noticeFile = join(dirname(CONFIG_PATH), "skills-notice.json");
let shown: Record<string, number> = {};
try {
shown = JSON.parse(readFileSync(noticeFile, "utf-8")) as Record<string, number>;
} catch {
// first notice, or a damaged file that the write below replaces
}
if (now - (shown[project] ?? 0) < CHECK_INTERVAL_MS) return false;
const kept = Object.entries(shown).filter(([, at]) => now - at < CHECK_INTERVAL_MS);
try {
mkdirSync(dirname(noticeFile), { recursive: true, mode: 0o700 });
writeFileSync(noticeFile, JSON.stringify(Object.fromEntries([...kept, [project, now]])));
} catch {
return false;
}
return true;
}
/** The stale-skills nudge text, or null when nothing is outdated or missing. */
function skillsNoticeText(meta: SkillsUpdateMeta): string | null {
const total = meta.outdated + meta.missing;
@@ -108,11 +131,13 @@ function skillsNoticeText(meta: SkillsUpdateMeta): string | null {
}
/**
* Print a one-line nudge to stderr if installed skills are stale. Off in CI, dev,
* HYPERFRAMES_NO_UPDATE_CHECK and HYPERFRAMES_SKIP_SKILLS; on without a TTY.
* Print a one-line nudge to stderr if installed skills are stale, at most once a day per project.
* Off in CI, dev, HYPERFRAMES_NO_UPDATE_CHECK and HYPERFRAMES_SKIP_SKILLS. Unattended, only
* missing skills count: an agent without them cannot do the work, an outdated one still can.
*/
export function printSkillsUpdateNotice(): void {
export function printSkillsUpdateNotice(cwd: string = process.cwd()): void {
if (skillsNoticeOff()) return;
const text = skillsNoticeText(getSkillsUpdateMeta());
if (text) process.stderr.write(text);
const meta = getSkillsUpdateMeta();
const text = skillsNoticeText(isUnattended() ? { ...meta, outdated: 0 } : meta);
if (text && claimDailyNotice(resolve(cwd))) process.stderr.write(text);
}