mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
co-authored by
Claude Opus 5.5
parent
3d1c0764a0
commit
e2c9151320
@@ -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`
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user