Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/help-agent-install-line.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@taskless/cli": patch
---

Every `--help` now opens with a line telling an agent that wants to install or update Taskless to run `init` first. An agent without the Taskless skill tends to start from `--help` and improvise commands; `init` installs the skill and recipes that keep sessions consistent. The command names the launcher and the exact build you ran, so a pinned nightly points at itself.
2 changes: 1 addition & 1 deletion .changeset/init-stale-cli-pins.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
"@taskless/cli": patch
---

`taskless init` names any `package.json` pin of `@taskless/cli` or `@taskless/cli-nightly` that would run an older CLI than the one that just ran (a dependency whose installed build or range is behind, or a script spelling out an older version), with the version to move it to, and offers the bump. Scripts, CI and git hooks run that pin, and a CLI older than the project's `.taskless/` refuses the layout. The install does not edit `package.json`. The workspace packages a project declares (`pnpm-workspace.yaml`, or the `workspaces` field) are read as well as the root, and a dependency is judged by the version that package actually runs: its own `node_modules` link, else the hoisted one. `init --json` and `info --json` both carry the pins as `pinnedCli`, each naming its `manifest`, and plain `info` lists them. The `init` (topic v4), `info` (topic v2) and `update` (topic v15) recipes tell an agent to offer the bump, and `update` now reads the pins from `info --json` rather than re-running `init`.
`taskless init` names any `package.json` pin of `@taskless/cli` or `@taskless/cli-nightly` that would run an older CLI than the one that just ran (a dependency whose installed build or range is behind, or a script spelling out an older version), with the version to move it to, and offers the bump. The notice is boxed like the restart-your-agents banner, so it is not lost in the install output. Scripts, CI and git hooks run that pin, and a CLI older than the project's `.taskless/` refuses the layout. The install does not edit `package.json`. The workspace packages a project declares (`pnpm-workspace.yaml`, or the `workspaces` field) are read as well as the root, and a dependency is judged by the version that package actually runs: its own `node_modules` link, else the hoisted one. `init --json` and `info --json` both carry the pins as `pinnedCli`, each naming its `manifest`, and plain `info` lists them. The `init` (topic v4), `info` (topic v2) and `update` (topic v15) recipes tell an agent to offer the bump, and `update` now reads the pins from `info --json` rather than re-running `init`.
3 changes: 2 additions & 1 deletion packages/cli/src/commands/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,8 @@ export const infoCommand = defineCommand({
if (pinnedCli.length > 0) {
console.log("");
console.log(`Pinned CLI older than v${__VERSION__}:`);
for (const pin of pinnedCli) console.log(describePin(pin, __VERSION__));
for (const pin of pinnedCli)
console.log(` - ${describePin(pin, __VERSION__)}`);
}

console.log("");
Expand Down
100 changes: 100 additions & 0 deletions packages/cli/src/install/notice-box.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
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 orange box an install draws around a notice it cannot afford to have
* scrolled past. Shared so every such notice reads as the same kind of thing.
*/

/** 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.
*
* 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 the
* original banner import that file for `getCliVersion`. That held, and held
* for a reason no reader of this file could see: a caller that did not import
* the wizard would have got a colourless box with no error and nothing to grep
* for.
*/
const ACCENT = "#ff8c00";

/**
* One block of a notice: a paragraph wrapped to the box, or a bulleted list
* whose items wrap under their own text rather than under the bullet.
*/
export type NoticeBlock = string | { items: readonly string[] };

/**
* 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;
}

function renderBlock(block: NoticeBlock): string[] {
if (typeof block === "string") return wrap(block, WRAP_COLUMNS);
return block.items.flatMap((item) =>
wrap(item, WRAP_COLUMNS - 2).map(
(line, index) => `${index === 0 ? "-" : " "} ${line}`
)
);
}

/** Draw `heading` and `blocks`, separated by blank lines, inside the box. */
export function renderNoticeBox(
heading: string,
blocks: readonly NoticeBlock[]
): string {
const body = blocks.flatMap((block, index) => [
...(index === 0 ? [] : [""]),
...renderBlock(block),
]);

// 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) => edge.bold(value)),
row("", (value) => value),
...body.map((line) => row(line, (value) => value)),
bottom,
"",
].join("\n");
}
11 changes: 6 additions & 5 deletions packages/cli/src/install/pinned-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { parse as parseYaml } from "yaml";

import { isRecord } from "../util/is-record";
import { compareSemver, compareVersions } from "../util/version-compare";
import { renderNoticeBox } from "./notice-box";

/**
* Pins in `package.json` that would run a Taskless CLI older than the one that
Expand Down Expand Up @@ -372,7 +373,7 @@ function bumpTarget(cliVersion: string): { name: string; version: string } {
}

/**
* One notice line: which manifest and field the pin is in, what it says, and
* One pin, unbulleted: which manifest and field the pin is in, what it says, and
* what to change it to. The manifest is named even at the root, so a list
* mixing the root with workspace packages reads the same way throughout.
*/
Expand All @@ -384,7 +385,7 @@ export function describePin(pin: PinnedCli, cliVersion: string): string {
pin.name === target.name
? `${target.name}@${target.version}`
: `${target.name}@${target.version}, replacing ${pin.name}`;
return ` - ${pin.manifest} ${pin.location}: ${pin.name} ${pin.spec}${installed} -> ${move}`;
return `${pin.manifest} ${pin.location}: ${pin.name} ${pin.spec}${installed} -> ${move}`;
}

/**
Expand Down Expand Up @@ -426,9 +427,9 @@ export function getPinnedCliNotice(
: `This upgrade migrated .taskless/ from schema version ${String(upgraded.from)} to ${String(upgraded.to)}, and a CLI that predates that schema refuses the project (SCAFFOLD_VERSION_MISMATCH). ` +
`CI, scripts, and git hooks that run these pins will break on the push that carries the migrated files. ` +
`Offer to update them as shown and reinstall dependencies, in the same commit as .taskless/.`;
return [
return renderNoticeBox("UPDATE PINNED TASKLESS VERSIONS", [
`package.json pins a Taskless CLI older than ${cliVersion}, the version that just ran here:`,
...pins.map((pin) => describePin(pin, cliVersion)),
{ items: pins.map((pin) => describePin(pin, cliVersion)) },
consequence,
].join("\n");
]);
}
95 changes: 8 additions & 87 deletions packages/cli/src/install/reload-notice.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,4 @@
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";
import { renderNoticeBox } from "./notice-box";

/**
* The banner an upgrade owes a session that is already running.
Expand Down Expand Up @@ -33,45 +28,6 @@ export interface ReloadNoticeInput {
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.
*
* 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";

/**
* 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.
Expand All @@ -98,46 +54,11 @@ export function versionMoved(input: ReloadNoticeInput): boolean {
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) => edge.bold(value)),
row("", (value) => value),
...body.map((line) => row(line, (value) => value)),
bottom,
"",
].join("\n");
return renderNoticeBox("RESTART YOUR AGENTS", [
`Taskless changed from ${input.previousCliVersion ?? ""} to ${input.cliVersion}.`,
"An AI session that is already open still holds the previous skills, " +
"because most tools read the skill list once, at startup.",
"Reload skills in your AI tool, or start a new session, before asking " +
"it to use Taskless.",
]);
}
19 changes: 19 additions & 0 deletions packages/cli/src/util/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { showUsage } from "citty";
import type { ArgsDef, CommandDef, Resolvable } from "citty";

import { splitRawArguments } from "./argv";
import { getCliPrefix } from "./package-manager";

/**
* citty only implements `--help` inside `runMain`, and `runMain`'s help path
Expand Down Expand Up @@ -52,5 +53,23 @@ export async function showResolvedUsage<T extends ArgsDef = ArgsDef>(
command = child;
}

console.log(getAgentInstallLine());
console.log("");
await showUsage(command, parent);
}

/**
* Printed above every usage block, because `--help` is where an agent that
* does not have the Taskless skill yet goes looking. Help written for a
* person sends it improvising commands; `init` installs the skill and
* recipes that make every later session behave the same way.
*
* It names this exact build, as every remedy does: whoever launched a pinned
* nightly chose that version, and `init` should install the same one.
*/
export function getAgentInstallLine(): string {
return (
`If you're an agent looking to install or update Taskless, run \`${getCliPrefix()} init\` first. ` +
"It may change how this help works, but it is how agents get a consistent Taskless experience."
);
}
11 changes: 11 additions & 0 deletions packages/cli/test/help-flag.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,17 @@ describe("--help below the root", () => {
expect(stdout).toContain("COMMANDS");
expect(stdout).toContain("Manage authentication with taskless.io");
});

it.each([[["--help"]], [["auth", "login", "--help"]]])(
"opens %j with the line telling an agent to run init",
async (arguments_) => {
const { stdout } = await execFileAsync("node", [binPath, ...arguments_]);

const [first] = stdout.split("\n");
expect(first).toMatch(/^If you're an agent .* init` first\./);
expect(stdout.indexOf("USAGE")).toBeGreaterThan(0);
}
);
});

describe("splitRawArguments", () => {
Expand Down
36 changes: 25 additions & 11 deletions packages/cli/test/init-no-interactive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,18 @@ async function installAtVersion(cwd: string, version: string): Promise<void> {
await writeFile(manifestPath, JSON.stringify(manifest, null, 2));
}

/**
* The text of every boxed notice in `stdout`, rows joined back into one line,
* so a phrase the box wrapped across two rows can be asserted on whole.
*/
function boxedText(stdout: string): string {
return stdout
.split("\n")
.filter((line) => line.startsWith("│"))
.map((line) => line.slice(1, -1).trim())
.join(" ");
}

describe("taskless init (the batch install)", () => {
let cwd: string;

Expand Down Expand Up @@ -294,10 +306,12 @@ describe("taskless init (the batch install)", () => {
cwd,
]);

expect(stdout).toContain("devDependencies: @taskless/cli 0.0.1");
expect(stdout).toContain("scripts.lint: @taskless/cli 0.0.1");
expect(stdout).toMatch(/-> @taskless\/cli@\S+/);
expect(stdout).toContain("Offer to update them as shown");
const notice = boxedText(stdout);
expect(notice).toContain("UPDATE PINNED TASKLESS VERSIONS");
expect(notice).toContain("devDependencies: @taskless/cli 0.0.1");
expect(notice).toContain("scripts.lint: @taskless/cli 0.0.1");
expect(notice).toMatch(/-> @taskless\/cli@\S+/);
expect(notice).toContain("Offer to update them as shown");
expect(await readFile(join(cwd, "package.json"), "utf8")).toBe(packageJson);

// After the upgrade trailer, which it follows from; the onboarding line
Expand Down Expand Up @@ -336,9 +350,9 @@ describe("taskless init (the batch install)", () => {
expect(stdout).toContain(
`from schema version 2 to ${String(LATEST_SCHEMA_VERSION)}`
);
expect(stdout).toContain("SCAFFOLD_VERSION_MISMATCH");
expect(stdout).toContain("same commit as .taskless/");
expect(stdout).not.toContain("will likely fail");
expect(boxedText(stdout)).toContain("SCAFFOLD_VERSION_MISMATCH");
expect(boxedText(stdout)).toContain("same commit as .taskless/");
expect(boxedText(stdout)).not.toContain("will likely fail");
});

it("does not call a fresh install an upgrade", async () => {
Expand All @@ -357,8 +371,8 @@ describe("taskless init (the batch install)", () => {
]);

expect(stdout).toContain("devDependencies: @taskless/cli 0.0.1");
expect(stdout).toContain("will likely fail");
expect(stdout).not.toContain("SCAFFOLD_VERSION_MISMATCH");
expect(boxedText(stdout)).toContain("will likely fail");
expect(boxedText(stdout)).not.toContain("SCAFFOLD_VERSION_MISMATCH");
expect(stdout).not.toContain("This upgrade migrated");
});

Expand All @@ -381,8 +395,8 @@ describe("taskless init (the batch install)", () => {
expect(stdout).not.toContain("next commit");
expect(stdout).toContain("devDependencies: @taskless/cli ^0.0.1");
// Nothing migrated, so the layout the pin reads did not move.
expect(stdout).toContain("will likely fail");
expect(stdout).not.toContain("SCAFFOLD_VERSION_MISMATCH");
expect(boxedText(stdout)).toContain("will likely fail");
expect(boxedText(stdout)).not.toContain("SCAFFOLD_VERSION_MISMATCH");
});

it("carries stale pins on the --json envelope, as an empty list when there are none", async () => {
Expand Down
Loading
Loading