From e2c91513203ec9be1833840bcfb35183a3dcee8d Mon Sep 17 00:00:00 2001 From: James Russo Date: Wed, 28 Oct 2026 09:39:06 +0000 Subject: [PATCH] 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) * 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) * 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) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- docs/packages/cli.mdx | 10 ++- packages/cli/src/cli.agentMode.test.ts | 44 +++++++++- packages/cli/src/cli.ts | 88 +++++++++++++++---- packages/cli/src/help.ts | 19 ++-- .../src/utils/commandUsageResolution.test.ts | 26 ++++++ .../cli/src/utils/commandUsageResolution.ts | 14 ++- packages/cli/src/utils/didYouMean.test.ts | 24 +++++ packages/cli/src/utils/didYouMean.ts | 30 +++++++ .../cli/src/utils/reject-unknown-flags.ts | 14 ++- .../cli/src/utils/skillsUpdateCheck.test.ts | 47 ++++++++-- packages/cli/src/utils/skillsUpdateCheck.ts | 37 ++++++-- 11 files changed, 311 insertions(+), 42 deletions(-) create mode 100644 packages/cli/src/utils/didYouMean.test.ts create mode 100644 packages/cli/src/utils/didYouMean.ts diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index 29145bf4c1..c474c88417 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -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 --help` to see exactly what your copy accepts. +`npx hyperframes help ` (or `npx hyperframes --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` diff --git a/packages/cli/src/cli.agentMode.test.ts b/packages/cli/src/cli.agentMode.test.ts index 5b9a9023b1..a18b074686 100644 --- a/packages/cli/src/cli.agentMode.test.ts +++ b/packages/cli/src/cli.agentMode.test.ts @@ -70,7 +70,7 @@ interface Run { stderr: string; } -function runAsAgent(args: string[]): Promise { +function runAsAgent(args: string[], { ci = true } = {}): Promise { return new Promise((resolve, reject) => { const child = spawn("bun", ["run", cliEntry, ...args], { cwd: home, @@ -80,7 +80,7 @@ function runAsAgent(args: string[]): Promise { 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) => { diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 05498db21f..7e09b7ea2b 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -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 { 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( cmd: CommandDef, - parent?: CommandDef, + parent: CommandDef | undefined, + print: (text: string) => void, ): Promise { 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 { +// Prints the suggestion and the group's usage on stderr; the caller throws the returned error. +async function unknownCommandError(requested: ResolvedCommandUsage): Promise { + 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 { 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 { + 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 { 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)); } diff --git a/packages/cli/src/help.ts b/packages/cli/src/help.ts index 27cd45ed0c..8949e249e9 100644 --- a/packages/cli/src/help.ts +++ b/packages/cli/src/help.ts @@ -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 --help")} for more information about a command.`); + lines.push( + `Run ${c.cyan("hyperframes help ")} (or ${c.cyan(" --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 { +export async function showUsage( + cmd: CommandDef, + parent?: CommandDef, + print: (text: string) => void = console.log, +): Promise { 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 { 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(); + }); +}); diff --git a/packages/cli/src/utils/commandUsageResolution.ts b/packages/cli/src/utils/commandUsageResolution.ts index 6942d17852..14563f9066 100644 --- a/packages/cli/src/utils/commandUsageResolution.ts +++ b/packages/cli/src/utils/commandUsageResolution.ts @@ -5,6 +5,8 @@ type LazyCommandDef = CommandDef | (() => CommandDef | Promise); export interface ResolvedCommandUsage { command: CommandDef; parent?: CommandDef; + path: string[]; + unknownSubcommand?: string; } async function loadSubcommand(command: CommandDef, name: string): Promise { @@ -21,12 +23,20 @@ export async function resolveCommandUsage( ): Promise { 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 }; } diff --git a/packages/cli/src/utils/didYouMean.test.ts b/packages/cli/src/utils/didYouMean.test.ts new file mode 100644 index 0000000000..2b07a88e47 --- /dev/null +++ b/packages/cli/src/utils/didYouMean.test.ts @@ -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"); + }); +}); diff --git a/packages/cli/src/utils/didYouMean.ts b/packages/cli/src/utils/didYouMean.ts new file mode 100644 index 0000000000..067f423d4d --- /dev/null +++ b/packages/cli/src/utils/didYouMean.ts @@ -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 | 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; +} diff --git a/packages/cli/src/utils/reject-unknown-flags.ts b/packages/cli/src/utils/reject-unknown-flags.ts index df7eb86724..a049bce1a6 100644 --- a/packages/cli/src/utils/reject-unknown-flags.ts +++ b/packages/cli/src/utils/reject-unknown-flags.ts @@ -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 | null { return null; } +function unknownFlagError(flag: string, known: Set, 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, 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); } } diff --git a/packages/cli/src/utils/skillsUpdateCheck.test.ts b/packages/cli/src/utils/skillsUpdateCheck.test.ts index 0b7b3c5d3b..57ecaddeea 100644 --- a/packages/cli/src/utils/skillsUpdateCheck.test.ts +++ b/packages/cli/src/utils/skillsUpdateCheck.test.ts @@ -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; @@ -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 { + async function noticeTextFor(cache: FakeConfig, project = "/projects/a"): Promise { 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(); diff --git a/packages/cli/src/utils/skillsUpdateCheck.ts b/packages/cli/src/utils/skillsUpdateCheck.ts index 87b44f62a2..ced9be119b 100644 --- a/packages/cli/src/utils/skillsUpdateCheck.ts +++ b/packages/cli/src/utils/skillsUpdateCheck.ts @@ -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 = {}; + try { + shown = JSON.parse(readFileSync(noticeFile, "utf-8")) as Record; + } 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); }