From 5b128703830088f4764f2b2fd7d9a90937e18815 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 31 Aug 2026 16:27:28 -0700 Subject: [PATCH 1/2] feat(init): tell the user to restart their agents after a version move An AI tool reads its skill and command listing once, when the session starts. A session that is open during an install keeps serving the previous copy for the rest of its life, and nothing errors: the agent follows guidance one version out of date and reports success. That is worse here than for most tools, because a Taskless recipe is embedded in the bundle at build time rather than fetched. A stale skill names a stale CLI invocation, which serves a stale recipe, so the answer is wrong rather than missing. An agent following a recipe from an older build once authored four rules against a `language:` spelling the current build had already documented as wrong, and nothing in the run looked unusual. Keyed on the recorded `install.cliVersion` moving, read before `applyInstallPlan` records the new one. Any move counts: an upgrade, a downgrade, and a stable/nightly swap all leave the same stale copy in memory. A first install is not a move, and neither is re-running an install on the version already recorded, so both stay quiet. A banner that shows up when nothing changed is one people learn to scroll past, which would cost exactly the runs it exists for. The box is sized to its content so a 30-character nightly version widens it rather than breaking out, and padding is measured on the uncolored text: measuring after chalk has run counts escape sequences the terminal does not print and leaves every border ragged. The test asserts every row is one width, and fails on that specific mistake. --- .changeset/restart-agents-notice.md | 17 +++ packages/cli/src/commands/init.ts | 19 ++- packages/cli/src/install/reload-notice.ts | 139 ++++++++++++++++++++++ packages/cli/src/wizard/index.ts | 14 ++- packages/cli/test/reload-notice.test.ts | 117 ++++++++++++++++++ 5 files changed, 298 insertions(+), 8 deletions(-) create mode 100644 .changeset/restart-agents-notice.md create mode 100644 packages/cli/src/install/reload-notice.ts create mode 100644 packages/cli/test/reload-notice.test.ts diff --git a/.changeset/restart-agents-notice.md b/.changeset/restart-agents-notice.md new file mode 100644 index 00000000..0f2c85c5 --- /dev/null +++ b/.changeset/restart-agents-notice.md @@ -0,0 +1,17 @@ +--- +"@taskless/cli": patch +--- + +`init` now prints a banner when an install moves the CLI version, telling you to +reload skills or start a new session. + +An AI tool reads its skill and command listing once, at session start, so a +session that is open during an upgrade keeps serving the previous copy for the +rest of its life. Nothing errors. Because a Taskless recipe is embedded in the +bundle at build time rather than fetched, a stale skill names a stale CLI +invocation and serves a stale recipe, so the answer is wrong rather than +missing. + +The banner fires on a version move in either direction, which covers an upgrade, +a downgrade, and a stable/nightly swap. A first install is not a move and stays +quiet, as does re-running an install on the version already recorded. diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 48cca8bc..26a48dd0 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -12,6 +12,8 @@ import { getEmbeddedSkills, } from "../install/install"; import { getMandatorySkillNames } from "../install/catalog"; +import { getReloadNotice } from "../install/reload-notice"; +import { readInstallState } from "../install/state"; import { getTelemetry } from "../telemetry"; import { runWizard } from "../wizard"; import { getCliVersion } from "../wizard/intro"; @@ -86,6 +88,9 @@ export const initCommand = defineCommand({ } const result = await runNonInteractive(cwd); + if (result.reloadNotice !== undefined) { + console.log(result.reloadNotice); + } console.log( getOnboardTrailer({ commandsInstalled: result.commandsInstalled }) ); @@ -220,7 +225,7 @@ export const updateCommand = defineCommand({ async function runNonInteractive( cwd: string -): Promise<{ commandsInstalled: boolean }> { +): Promise<{ commandsInstalled: boolean; reloadNotice: string | undefined }> { // Sampled BEFORE the directory is created, and that order is the whole // point. `ensureTasklessDirectory` mkdir -p's, so afterwards a pre-existing // project is indistinguishable from a fresh one. @@ -249,9 +254,13 @@ async function runNonInteractive( (t) => t.mode === "reference" && t.commands.length > 0 ); - const result = await applyInstallPlan(cwd, plan, { - cliVersion: getCliVersion(), - }); + // Read BEFORE applying: `applyInstallPlan` records the new version, so + // afterwards there is nothing left to compare against. + const previousState = await readInstallState(cwd); + const previousCliVersion = previousState.cliVersion; + const cliVersion = getCliVersion(); + const result = await applyInstallPlan(cwd, plan, { cliVersion }); + const reloadNotice = getReloadNotice({ previousCliVersion, cliVersion }); if (detected.length === 0) { console.log(`No tools detected. Using fallback: ${DEFAULT_SHIM_DIR}/`); @@ -326,7 +335,7 @@ async function runNonInteractive( } } - return { commandsInstalled }; + return { commandsInstalled, reloadNotice }; } function groupValuesByTarget( diff --git a/packages/cli/src/install/reload-notice.ts b/packages/cli/src/install/reload-notice.ts new file mode 100644 index 00000000..5ad3d48b --- /dev/null +++ b/packages/cli/src/install/reload-notice.ts @@ -0,0 +1,139 @@ +import chalk from "chalk"; + +/** + * The banner an upgrade owes a session that is already running. + * + * Installing rewrites files an AI tool has usually ALREADY READ. A coding agent + * loads its skill and command listing once, when the session starts, so a + * session open at install time keeps serving the previous copy for the rest of + * its life. Nothing errors: the agent follows guidance one version out of date + * and reports success. + * + * That is worse here than it would be for most tools, because a Taskless recipe + * is embedded in the bundle at build time rather than fetched. A stale skill + * names a stale CLI invocation, which serves a stale recipe, so the answer is + * wrong rather than missing. An agent following a recipe from an older build + * once authored four rules against a `language:` spelling the current build had + * already documented as wrong, and nothing in the run looked unusual. + * + * The banner is loud on purpose. The failure it prevents is silent, arrives + * later, and does not look like an install problem when it does. + */ + +/** What this install did to the recorded version. */ +export interface ReloadNoticeInput { + /** The `install.cliVersion` recorded before this run, if there was one. */ + previousCliVersion?: string; + /** The version this run recorded. */ + cliVersion: string; +} + +/** Inner text is wrapped to this many columns before the box is sized. */ +const WRAP_COLUMNS = 62; + +/** + * Orange, downsampled by chalk to whatever the terminal actually supports. + * + * Colour depth is decided by `wizard/intro.ts`, which sets `chalk.level` at + * import time because chalk v5 otherwise bakes in the level it detected when + * the bundle was BUILT (no TTY, so level 0, so no colour ever). Both callers of + * this module already import that file for `getCliVersion`, so the level is set + * before this renders. Anything else that reaches for this banner must keep + * that true, or the box arrives colourless. + */ +const ACCENT = "#ff8c00"; + +/** + * Wrap on spaces, never mid-token. + * + * A nightly version is a single 30-character token, so a wrapper that split on + * width would cut one in half and produce a string nobody can copy. An + * over-long line is allowed to overflow instead, and the box is then sized + * around it. + */ +function wrap(text: string, columns: number): string[] { + const lines: string[] = []; + let line = ""; + for (const word of text.split(" ")) { + if (line === "") { + line = word; + } else if (line.length + 1 + word.length <= columns) { + line = `${line} ${word}`; + } else { + lines.push(line); + line = word; + } + } + if (line !== "") lines.push(line); + return lines; +} + +/** + * Whether this run changed the version, which is the only thing that makes an + * open session stale. + * + * A first install is not an upgrade: there was no earlier skill for a running + * session to be holding. Any move between two recorded versions counts, + * including a downgrade and including a stable/nightly swap, since both leave + * the same stale copy in memory. + */ +function versionMoved(input: ReloadNoticeInput): boolean { + return ( + input.previousCliVersion !== undefined && + input.previousCliVersion !== input.cliVersion + ); +} + +/** + * The banner, or `undefined` when this run did not move the version. + * + * Printed on the transition rather than on every install. A banner that shows + * up on runs where nothing changed is one people learn to scroll past, which + * would cost exactly the runs it exists for. + */ +export function getReloadNotice(input: ReloadNoticeInput): string | undefined { + if (!versionMoved(input)) return undefined; + + const body = [ + ...wrap( + `Taskless changed from ${input.previousCliVersion ?? ""} to ${input.cliVersion}.`, + WRAP_COLUMNS + ), + "", + ...wrap( + "An AI session that is already open still holds the previous skills, " + + "because most tools read the skill list once, at startup.", + WRAP_COLUMNS + ), + "", + ...wrap( + "Reload skills in your AI tool, or start a new session, before asking " + + "it to use Taskless.", + WRAP_COLUMNS + ), + ]; + + const heading = "RESTART YOUR AGENTS"; + // Sized to the content, so a long nightly version widens the box rather than + // breaking out of it. Padding is computed on the UNCOLORED text: measuring + // after chalk has run would count escape sequences as characters and leave + // every border ragged. + const inner = + Math.max(heading.length, ...body.map((line) => line.length)) + 4; + + const edge = chalk.hex(ACCENT); + const top = edge(`┌${"─".repeat(inner)}┐`); + const bottom = edge(`└${"─".repeat(inner)}┘`); + const row = (text: string, render: (value: string) => string) => + `${edge("│")} ${render(text)}${" ".repeat(inner - text.length - 4)} ${edge("│")}`; + + return [ + "", + top, + row(heading, (value) => chalk.hex(ACCENT).bold(value)), + row("", (value) => value), + ...body.map((line) => row(line, (value) => value)), + bottom, + "", + ].join("\n"); +} diff --git a/packages/cli/src/wizard/index.ts b/packages/cli/src/wizard/index.ts index c6e47617..44609269 100644 --- a/packages/cli/src/wizard/index.ts +++ b/packages/cli/src/wizard/index.ts @@ -9,6 +9,7 @@ import { getEmbeddedSkills, planToStateTargets, } from "../install/install"; +import { getReloadNotice } from "../install/reload-notice"; import { computeInstallDiff, readInstallState } from "../install/state"; import { getTelemetry } from "../telemetry"; @@ -77,14 +78,21 @@ export async function runWizard( await ensureTasklessDirectory(options.cwd, { onNotice: (message) => log.info(message), }); - await applyInstallPlan(options.cwd, plan, { - cliVersion: getCliVersion(), - }); + const cliVersion = getCliVersion(); + await applyInstallPlan(options.cwd, plan, { cliVersion }); outro("Taskless is ready to go."); const commandsInstalled = plan.targets.some( (t) => t.mode === "reference" && t.commands.length > 0 ); + // `previousState` was read before the plan was applied, so it still holds + // the version this project was on when the session that is watching this + // install started. + const reloadNotice = getReloadNotice({ + previousCliVersion: previousState.cliVersion, + cliVersion, + }); + if (reloadNotice !== undefined) console.log(reloadNotice); console.log(getOnboardTrailer({ commandsInstalled })); return finish({ status: "completed" }); } catch (error) { diff --git a/packages/cli/test/reload-notice.test.ts b/packages/cli/test/reload-notice.test.ts new file mode 100644 index 00000000..38f288bd --- /dev/null +++ b/packages/cli/test/reload-notice.test.ts @@ -0,0 +1,117 @@ +import chalk from "chalk"; +import { beforeAll, describe, expect, it } from "vitest"; + +import { getReloadNotice } from "../src/install/reload-notice"; + +/** + * The banner is a box, and a box is only worth printing if it lines up. Every + * width assertion here measures the UNCOLORED text: chalk emits escape + * sequences that `String.length` counts and a terminal does not, so asserting + * on the raw string would pass while the rendered box was ragged. + */ +// eslint-disable-next-line no-control-regex +const ANSI = /\[[0-9;]*m/g; +const plain = (value: string) => value.replaceAll(ANSI, ""); + +const NIGHTLY = "0.11.1-20260831132610x088fa7c"; +const OLDER_NIGHTLY = "0.11.0-20260826193918xdde086c"; + +beforeAll(() => { + // Force colour on, so the alignment assertions run against the string a + // terminal actually receives rather than a plain one. Without this the + // escape-stripping above would be untested. + chalk.level = 3; +}); + +describe("the restart-your-agents banner", () => { + // The trigger, stated as the two things that are NOT an upgrade. A banner on + // every install is one people learn to scroll past. + it("says nothing on a first install", () => { + expect(getReloadNotice({ cliVersion: "0.11.1" })).toBeUndefined(); + }); + + it("says nothing when the version did not move", () => { + expect( + getReloadNotice({ previousCliVersion: "0.11.1", cliVersion: "0.11.1" }) + ).toBeUndefined(); + }); + + it.each([ + ["stable to nightly", "0.11.0", NIGHTLY], + ["nightly to stable", NIGHTLY, "0.11.0"], + ["nightly to nightly", OLDER_NIGHTLY, NIGHTLY], + ["a downgrade", "0.11.1", "0.11.0"], + ])("fires on %s", (_label, previousCliVersion, cliVersion) => { + // A swap in either direction leaves the same stale copy in a running + // session, so direction is not the question. Movement is. + const notice = getReloadNotice({ previousCliVersion, cliVersion }); + expect(notice).toBeDefined(); + expect(plain(notice as string)).toContain("RESTART YOUR AGENTS"); + }); + + it("names both versions so the reader can see which way it went", () => { + const notice = plain( + getReloadNotice({ + previousCliVersion: OLDER_NIGHTLY, + cliVersion: NIGHTLY, + }) as string + ); + expect(notice).toContain(OLDER_NIGHTLY); + expect(notice).toContain(NIGHTLY); + }); + + it("tells the reader what to do, not only what happened", () => { + const notice = plain( + getReloadNotice({ + previousCliVersion: "0.11.0", + cliVersion: NIGHTLY, + }) as string + ); + expect(notice).toMatch(/Reload skills|start a new session/); + }); + + it.each([ + ["short versions", "0.11.0", "0.11.1"], + ["a long nightly on one side", "0.11.0", NIGHTLY], + ["a long nightly on both sides", OLDER_NIGHTLY, NIGHTLY], + ])( + "draws a box whose every row is the same width: %s", + (_label, previousCliVersion, cliVersion) => { + const rows = plain( + getReloadNotice({ previousCliVersion, cliVersion }) as string + ) + .split("\n") + .filter((line) => line !== ""); + + const widths = new Set(rows.map((line) => line.length)); + expect(widths, `rows: ${[...widths].join(", ")}`).toHaveLength(1); + } + ); + + it("never splits a version across two lines", () => { + // A wrapped nightly version is a string nobody can copy, and the versions + // are the one part of this banner a reader may need verbatim. + const rows = plain( + getReloadNotice({ + previousCliVersion: OLDER_NIGHTLY, + cliVersion: NIGHTLY, + }) as string + ).split("\n"); + + for (const version of [OLDER_NIGHTLY, NIGHTLY]) { + expect( + rows.some((line) => line.includes(version)), + `${version} is broken across lines` + ).toBe(true); + } + }); + + it("carries colour, and survives having it stripped", () => { + const notice = getReloadNotice({ + previousCliVersion: "0.11.0", + cliVersion: NIGHTLY, + }) as string; + expect(notice).not.toBe(plain(notice)); + expect(plain(notice)).toContain("┌"); + }); +}); From 33059062cf763667e1f0e20a5ce93337463dd6ea Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 31 Aug 2026 16:53:22 -0700 Subject: [PATCH 2/2] fix(init): own the colour contract, and test the banner is wired up Review feedback on #228. The banner's colour depended on an ordering nothing enforced: chalk v5 detects depth at import time, the bundle is built with no TTY, so the level has to be set at runtime, and the only copy of that logic lived in `wizard/intro.ts`. Both callers of the banner import that file for `getCliVersion`, so it worked, for a reason no reader of `reload-notice` could see. A third caller that did not import the wizard would have got a colourless box with no error and nothing to grep for. `detectColorLevel` moves to `util/color.ts`, which applies the level on import. `wizard/intro.ts` and `reload-notice.ts` each import it for that side effect, so a module that renders colour establishes the contract itself instead of inheriting it by luck. The function had no coverage anywhere; it now has the precedence pinned, since each rung is a promise to someone: NO_COLOR to a pipe, FORCE_COLOR to CI, the TTY check to everyone else. Also drops a second `chalk.hex(ACCENT)` where `edge` was already built. The wiring is now tested end to end. `reload-notice.test.ts` covers the decision as a pure function and cannot see whether `init` calls it and prints the result, which is exactly where this feature fails silently: a dropped `console.log` leaves every unit test green while the user is told nothing. Confirmed by deleting that line and watching the new test fail. The upgrade case stages an older version in the manifest, because one build cannot report two versions in one process. Declined the third suggestion, a duplicated manifest read in `runNonInteractive`. Removing it means widening `ApplyInstallResult` to hand back state the caller can already read, to save one read on a command that touches the filesystem dozens of times. --- packages/cli/src/install/reload-notice.ts | 18 ++-- packages/cli/src/util/color.ts | 37 ++++++++ packages/cli/src/wizard/intro.ts | 30 ++----- packages/cli/test/color.test.ts | 90 +++++++++++++++++++ packages/cli/test/init-no-interactive.test.ts | 85 +++++++++++++++++- 5 files changed, 227 insertions(+), 33 deletions(-) create mode 100644 packages/cli/src/util/color.ts create mode 100644 packages/cli/test/color.test.ts diff --git a/packages/cli/src/install/reload-notice.ts b/packages/cli/src/install/reload-notice.ts index 5ad3d48b..877b49fb 100644 --- a/packages/cli/src/install/reload-notice.ts +++ b/packages/cli/src/install/reload-notice.ts @@ -1,5 +1,10 @@ import chalk from "chalk"; +// Sets `chalk.level` from the real terminal on import. This module renders +// colour, so it establishes that itself rather than inheriting it from +// whichever caller happened to load `wizard/intro.ts` first. +import "../util/color"; + /** * The banner an upgrade owes a session that is already running. * @@ -34,12 +39,11 @@ const WRAP_COLUMNS = 62; /** * Orange, downsampled by chalk to whatever the terminal actually supports. * - * Colour depth is decided by `wizard/intro.ts`, which sets `chalk.level` at - * import time because chalk v5 otherwise bakes in the level it detected when - * the bundle was BUILT (no TTY, so level 0, so no colour ever). Both callers of - * this module already import that file for `getCliVersion`, so the level is set - * before this renders. Anything else that reaches for this banner must keep - * that true, or the box arrives colourless. + * Depth comes from `util/color`, imported above for that side effect. It used + * to come from `wizard/intro.ts` by accident, because both callers of this + * module import that file for `getCliVersion`. That held, and held for a reason + * no reader of this file could see: a third caller that did not import the + * wizard would have got a colourless box with no error and nothing to grep for. */ const ACCENT = "#ff8c00"; @@ -130,7 +134,7 @@ export function getReloadNotice(input: ReloadNoticeInput): string | undefined { return [ "", top, - row(heading, (value) => chalk.hex(ACCENT).bold(value)), + row(heading, (value) => edge.bold(value)), row("", (value) => value), ...body.map((line) => row(line, (value) => value)), bottom, diff --git a/packages/cli/src/util/color.ts b/packages/cli/src/util/color.ts new file mode 100644 index 00000000..86e5913a --- /dev/null +++ b/packages/cli/src/util/color.ts @@ -0,0 +1,37 @@ +import chalk from "chalk"; + +/** + * Set `chalk.level` from the terminal we are ACTUALLY running in. + * + * Chalk v5 auto-detects at import time. When the CLI is bundled by Vite there + * is no TTY at build time, so the detection is baked in as `level: 0` and every + * colour is silently stripped from the shipped binary. Re-detecting at runtime + * is what makes colour work at all here. + * + * IMPORTING THIS MODULE IS WHAT APPLIES IT. The level is a single global on a + * shared chalk instance, so any module that renders colour needs it to have run + * first. Leaving that to "some caller will have imported the right file" + * produces a colourless box with no error, which is the failure mode this + * module exists to remove: previously the only copy of this logic lived in + * `wizard/intro.ts` and everything else inherited it by accident. + */ +export function detectColorLevel(): 0 | 1 | 2 | 3 { + if (process.env.NO_COLOR) return 0; + const force = process.env.FORCE_COLOR; + if (force === "0") return 0; + if (force === "1") return 1; + if (force === "2") return 2; + if (force === "3") return 3; + const isTTY = process.stdout.isTTY === true || process.stderr.isTTY === true; + if (!isTTY) return 0; + const term = process.env.TERM ?? ""; + const colorterm = process.env.COLORTERM ?? ""; + if (colorterm === "truecolor" || colorterm === "24bit") return 3; + if (/-256(color)?$/i.test(term)) return 2; + if (term === "" || term === "dumb") return 0; + return 1; +} + +// Applied on import, so a module that renders colour gets it by importing this +// rather than by depending on an unrelated module having been loaded first. +chalk.level = detectColorLevel(); diff --git a/packages/cli/src/wizard/intro.ts b/packages/cli/src/wizard/intro.ts index 4ec198dd..2f429336 100644 --- a/packages/cli/src/wizard/intro.ts +++ b/packages/cli/src/wizard/intro.ts @@ -1,34 +1,14 @@ import chalk from "chalk"; +// Sets `chalk.level` from the real terminal on import. Kept as an explicit +// import because this module renders colour and must not rely on another +// module having loaded it first. +import "../util/color"; + declare const __VERSION__: string; const CLI_VERSION: string = typeof __VERSION__ === "string" ? __VERSION__ : "unknown"; -/** - * Chalk v5's auto-detection runs at import time. When the CLI is bundled via - * Vite (no TTY at build time), it gets baked in as `level: 0`, which silently - * strips every color. Re-detect at runtime and overwrite chalk.level so the - * bundled binary picks up the terminal's actual capabilities. - */ -function detectColorLevel(): 0 | 1 | 2 | 3 { - if (process.env.NO_COLOR) return 0; - const force = process.env.FORCE_COLOR; - if (force === "0") return 0; - if (force === "1") return 1; - if (force === "2") return 2; - if (force === "3") return 3; - const isTTY = process.stdout.isTTY === true || process.stderr.isTTY === true; - if (!isTTY) return 0; - const term = process.env.TERM ?? ""; - const colorterm = process.env.COLORTERM ?? ""; - if (colorterm === "truecolor" || colorterm === "24bit") return 3; - if (/-256(color)?$/i.test(term)) return 2; - if (term === "" || term === "dumb") return 0; - return 1; -} - -chalk.level = detectColorLevel(); - /** * The Taskless wordmark rendered as 60×5 quad-block ASCII. Produced offline * from tmp/logo-dark-on-white.png via tmp/ascii-tool/convert.mjs; see diff --git a/packages/cli/test/color.test.ts b/packages/cli/test/color.test.ts new file mode 100644 index 00000000..2be4a559 --- /dev/null +++ b/packages/cli/test/color.test.ts @@ -0,0 +1,90 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { detectColorLevel } from "../src/util/color"; + +/** + * Colour depth is decided at RUNTIME, not at build time, and that is the whole + * reason this function exists: chalk v5 detects on import, the bundle is built + * with no TTY, so a shipped binary that trusted chalk's own answer would strip + * every colour and never say why. + * + * These assertions pin the precedence, because each rung is a documented + * promise to someone: `NO_COLOR` to anyone piping output, `FORCE_COLOR` to CI, + * and the TTY check to everything else. + */ + +/** + * Pretend stdout and stderr are (or are not) a terminal. + * + * Assigned rather than spied: `isTTY` is a plain own property that Node only + * defines when the stream IS a tty, so there is no getter to intercept when it + * matters most. `vi.spyOn(stream, "isTTY", "get")` throws "isTTY does not + * exist" under exactly the non-tty case these tests care about. + */ +const originalTTY = { + stdout: process.stdout.isTTY, + stderr: process.stderr.isTTY, +}; + +function withTTY(isTTY: boolean) { + process.stdout.isTTY = isTTY; + process.stderr.isTTY = isTTY; +} + +afterEach(() => { + process.stdout.isTTY = originalTTY.stdout; + process.stderr.isTTY = originalTTY.stderr; + vi.unstubAllEnvs(); + vi.restoreAllMocks(); +}); + +describe("runtime colour detection", () => { + it("honours NO_COLOR above everything else", () => { + // Set the two things that would otherwise force colour on. NO_COLOR is a + // cross-tool convention; losing to FORCE_COLOR would break a pipe. + vi.stubEnv("NO_COLOR", "1"); + vi.stubEnv("FORCE_COLOR", "3"); + withTTY(true); + expect(detectColorLevel()).toBe(0); + }); + + it.each([ + ["0", 0], + ["1", 1], + ["2", 2], + ["3", 3], + ])("takes FORCE_COLOR=%s as level %i, with no TTY", (force, expected) => { + // The CI case: no terminal, colour wanted anyway. + vi.stubEnv("NO_COLOR", ""); + vi.stubEnv("FORCE_COLOR", force); + withTTY(false); + expect(detectColorLevel()).toBe(expected); + }); + + it("reports no colour when nothing is a terminal", () => { + vi.stubEnv("NO_COLOR", ""); + vi.stubEnv("FORCE_COLOR", ""); + withTTY(false); + expect(detectColorLevel()).toBe(0); + }); + + it.each([ + ["truecolor", "xterm", 3], + ["24bit", "xterm", 3], + ["", "xterm-256color", 2], + ["", "screen-256color", 2], + ["", "xterm", 1], + ["", "dumb", 0], + ["", "", 0], + ])( + "reads COLORTERM=%s TERM=%s as level %i on a terminal", + (colorterm, term, expected) => { + vi.stubEnv("NO_COLOR", ""); + vi.stubEnv("FORCE_COLOR", ""); + vi.stubEnv("COLORTERM", colorterm); + vi.stubEnv("TERM", term); + withTTY(true); + expect(detectColorLevel()).toBe(expected); + } + ); +}); diff --git a/packages/cli/test/init-no-interactive.test.ts b/packages/cli/test/init-no-interactive.test.ts index f390e03b..22e24f7d 100644 --- a/packages/cli/test/init-no-interactive.test.ts +++ b/packages/cli/test/init-no-interactive.test.ts @@ -1,5 +1,12 @@ import { execFile } from "node:child_process"; -import { mkdir, mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { + mkdir, + mkdtemp, + readFile, + rm, + stat, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { promisify } from "node:util"; @@ -17,6 +24,17 @@ async function exists(path: string): Promise { } } +/** Run a real install in `cwd`, then rewrite the recorded version. */ +async function installAtVersion(cwd: string, version: string): Promise { + await execFileAsync("node", [binPath, "init", "--no-interactive", "-d", cwd]); + const manifestPath = join(cwd, ".taskless", "taskless.json"); + const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as { + install?: { cliVersion?: string }; + }; + if (manifest.install) manifest.install.cliVersion = version; + await writeFile(manifestPath, JSON.stringify(manifest, null, 2)); +} + describe("taskless init --no-interactive", () => { let cwd: string; @@ -221,4 +239,69 @@ describe("taskless init --no-interactive", () => { ) as { install?: { onboarded?: boolean } }; expect(manifest.install?.onboarded).toBe(true); }); + + /** + * The banner is wired up, not merely returned. + * + * `reload-notice.test.ts` covers the decision thoroughly as a pure function. + * What it cannot see is whether `init` calls it and prints the result, and + * that is where this feature fails silently: a dropped `console.log` or a + * swapped field leaves every unit test green while the user is told nothing + * and their agent keeps serving the previous skills. + */ + describe("the restart-your-agents banner", () => { + it("prints on a second install whose recorded version moved", async () => { + // Planting an older version is what makes the next run an upgrade. The + // build under test cannot report two versions in one process, so the + // move is staged in the manifest rather than by installing twice. + await installAtVersion(cwd, "0.0.1-planted"); + + const { stdout } = await execFileAsync("node", [ + binPath, + "init", + "--no-interactive", + "-d", + cwd, + ]); + + expect(stdout).toContain("RESTART YOUR AGENTS"); + // The version it moved FROM, which is the half a reader needs to tell an + // upgrade from a downgrade. + expect(stdout).toContain("0.0.1-planted"); + }); + + it("stays quiet on a first install", async () => { + const { stdout } = await execFileAsync("node", [ + binPath, + "init", + "--no-interactive", + "-d", + cwd, + ]); + + expect(stdout).not.toContain("RESTART YOUR AGENTS"); + }); + + it("stays quiet when the recorded version did not move", async () => { + // The re-run case. A banner here would appear on every ordinary install + // and train people to scroll past it. + await execFileAsync("node", [ + binPath, + "init", + "--no-interactive", + "-d", + cwd, + ]); + + const { stdout } = await execFileAsync("node", [ + binPath, + "init", + "--no-interactive", + "-d", + cwd, + ]); + + expect(stdout).not.toContain("RESTART YOUR AGENTS"); + }); + }); });