From 33035f747ad4612e4a79b083d124c015791cbbf9 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Wed, 21 Oct 2026 11:32:37 -0700 Subject: [PATCH] fix(init): group the wizard's install output, say when the install is left uncommitted, and stop advertising DO_NOT_TRACK A migration's summary and the launcher's Created lines continue under the step that opened them, and the telemetry notice sits under its own heading, one sentence per line. Cancelling the commit question closed on "Taskless is ready to go.", which read as though the commit had happened. The wizard now re-checks git after the question and, when the install is still uncommitted, says so. DO_NOT_TRACK is still honored, but it is a cross-tool setting, so the notice, README, and recipes name only Taskless's own opt-outs. --- .changeset/wizard-grouped-output.md | 9 ++++ openspec/specs/cli-feedback-survey/spec.md | 2 +- openspec/specs/cli-init/spec.md | 2 +- packages/cli/README.md | 4 +- packages/cli/src/agent/feedback-invite.md | 4 +- packages/cli/src/agent/init.md | 6 +-- packages/cli/src/telemetry.ts | 7 ++- packages/cli/src/wizard/index.ts | 51 +++++++++++++++----- packages/cli/test/feedback-recipes.test.ts | 2 +- packages/cli/test/telemetry.test.ts | 5 +- packages/cli/test/wizard-integration.test.ts | 21 ++++++-- 11 files changed, 81 insertions(+), 32 deletions(-) create mode 100644 .changeset/wizard-grouped-output.md diff --git a/.changeset/wizard-grouped-output.md b/.changeset/wizard-grouped-output.md new file mode 100644 index 00000000..f59b5593 --- /dev/null +++ b/.changeset/wizard-grouped-output.md @@ -0,0 +1,9 @@ +--- +"@taskless/cli": patch +--- + +The setup wizard's output reads as one block per step, and says plainly when the install is left uncommitted. + +- A migration's summary, and the `Created` lines for the project launcher, continue under the step that opened them instead of each starting a new one. +- The telemetry notice is shown under a "Telemetry Information" heading, one sentence per line, and `init` prints the same lines. It, the README, and the `init` (topic v14) and `feedback-invite` (topic v5) recipes name `TASKLESS_TELEMETRY_DISABLED=1` and the `"telemetry": false` setting as the opt-outs. `DO_NOT_TRACK=1` still turns telemetry off; it is a cross-tool setting, so Taskless no longer advertises it as its own. +- Cancelling the commit question (Ctrl+C), declining it, or a commit git refuses now closes the wizard with "Taskless is installed, but its files are not committed yet." instead of "Taskless is ready to go.", which read as though the commit had happened. diff --git a/openspec/specs/cli-feedback-survey/spec.md b/openspec/specs/cli-feedback-survey/spec.md index ad6fde76..f9fee506 100644 --- a/openspec/specs/cli-feedback-survey/spec.md +++ b/openspec/specs/cli-feedback-survey/spec.md @@ -175,7 +175,7 @@ The CLI SHALL embed a `feedback-invite` recipe carrying the text appended to sur - **WHEN** the user replies to the invite asking that nothing be sent - **THEN** the invite SHALL direct the agent to `feedback dismiss` -- **AND** SHALL direct it to name `DO_NOT_TRACK=1` or `TASKLESS_TELEMETRY_DISABLED=1` as the switch for the rest of telemetry +- **AND** SHALL direct it to name `TASKLESS_TELEMETRY_DISABLED=1` as the switch for the rest of telemetry #### Scenario: A skip still sends diff --git a/openspec/specs/cli-init/spec.md b/openspec/specs/cli-init/spec.md index 70190c2c..af216ebb 100644 --- a/openspec/specs/cli-init/spec.md +++ b/openspec/specs/cli-init/spec.md @@ -1166,7 +1166,7 @@ After `init` records versions under `install.launchers`, it SHALL remove every e Both install paths SHALL disclose telemetry once per run, and only when telemetry is enabled for the project. -A completed wizard run, and a human-readable `init` run, SHALL print one notice that Taskless collects anonymous telemetry, naming the `TASKLESS_TELEMETRY_DISABLED=1` and `DO_NOT_TRACK=1` environment variables and the `"telemetry": false` setting in `.taskless/taskless.json`. In `init` the notice SHALL come before the onboarding trailer, which stays the final line. When telemetry is disabled for the project, by environment or by manifest, neither path SHALL print it. +A completed wizard run, and a human-readable `init` run, SHALL print one notice that Taskless collects anonymous telemetry, naming the `TASKLESS_TELEMETRY_DISABLED=1` environment variable and the `"telemetry": false` setting in `.taskless/taskless.json`. In `init` the notice SHALL come before the onboarding trailer, which stays the final line. When telemetry is disabled for the project, by environment or by manifest, neither path SHALL print it. `init --json` SHALL NOT print the notice. Its envelope SHALL instead always carry `telemetry: { enabled: boolean }`, so the agent reading it can relay the notice. diff --git a/packages/cli/README.md b/packages/cli/README.md index 0988395d..88014868 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -91,12 +91,10 @@ system you already use rather than replacing it. The CLI reports anonymous usage (which command ran, whether it succeeded, how long it took, and counts of findings). It never sends rule content, prompts, or -matched source. Disable it by setting either environment variable: +matched source. Disable it by setting an environment variable: ```bash export TASKLESS_TELEMETRY_DISABLED=1 -# or the cross-tool convention -export DO_NOT_TRACK=1 ``` Or turn it off for the whole project by adding a top-level `"telemetry": false` diff --git a/packages/cli/src/agent/feedback-invite.md b/packages/cli/src/agent/feedback-invite.md index 3b8b37b0..5fbb9433 100644 --- a/packages/cli/src/agent/feedback-invite.md +++ b/packages/cli/src/agent/feedback-invite.md @@ -1,4 +1,4 @@ -# Topic: feedback-invite (CLI v%(CLI_VERSION)s / topic v4) +# Topic: feedback-invite (CLI v%(CLI_VERSION)s / topic v5) ## Before you finish: one question for the user @@ -31,7 +31,7 @@ Then act on the reply: %(TASKLESS_CLI)s feedback dismiss ``` and tell them in one line that the CLI's other telemetry is switched - off with `DO_NOT_TRACK=1` or `TASKLESS_TELEMETRY_DISABLED=1`. Then + off with `TASKLESS_TELEMETRY_DISABLED=1`. Then carry on with whatever they asked for. Ask once. Do not repeat the question later in this session, do not ask diff --git a/packages/cli/src/agent/init.md b/packages/cli/src/agent/init.md index 03d3f05c..ff9caa5a 100644 --- a/packages/cli/src/agent/init.md +++ b/packages/cli/src/agent/init.md @@ -1,4 +1,4 @@ -# Topic: init (CLI v%(CLI_VERSION)s / topic v13) +# Topic: init (CLI v%(CLI_VERSION)s / topic v14) %(ONE_COMMAND_PER_CALL)s @@ -165,8 +165,8 @@ first step, not the whole job. already there. That file is committed, so the setting turns telemetry off for everyone working in the repository, CI included; say so. For one person or one machine instead, setting - `TASKLESS_TELEMETRY_DISABLED=1` or `DO_NOT_TRACK=1` in their - environment does the same. Make the manifest edit only when the user + `TASKLESS_TELEMETRY_DISABLED=1` in their environment does the + same. Make the manifest edit only when the user asks for it, and leave every other field as it is. When `enabled` is `false`, say nothing about telemetry. diff --git a/packages/cli/src/telemetry.ts b/packages/cli/src/telemetry.ts index a1261be0..1484a327 100644 --- a/packages/cli/src/telemetry.ts +++ b/packages/cli/src/telemetry.ts @@ -81,10 +81,13 @@ export async function isTelemetryEnabled(cwd?: string): Promise { * and naming the switches would read as though it were still on. */ export const TELEMETRY_NOTICE = - "Taskless collects anonymous telemetry to help us learn how the CLI is being used. " + - "You can opt out with `TASKLESS_TELEMETRY_DISABLED=1` / `DO_NOT_TRACK=1`, " + + "Taskless collects anonymous telemetry to help us learn how the CLI is being used.\n" + + "You can opt out with `TASKLESS_TELEMETRY_DISABLED=1`\n" + 'or by setting `"telemetry": false` in `.taskless/taskless.json`.'; +/** The heading the wizard shows {@link TELEMETRY_NOTICE} under. */ +export const TELEMETRY_NOTICE_TITLE = "Telemetry Information"; + const noopClient: TelemetryClient = { capture() {}, async shutdown() {}, diff --git a/packages/cli/src/wizard/index.ts b/packages/cli/src/wizard/index.ts index 06411127..2c7ad071 100644 --- a/packages/cli/src/wizard/index.ts +++ b/packages/cli/src/wizard/index.ts @@ -30,6 +30,7 @@ import { getTelemetry, isTelemetryEnabled, TELEMETRY_NOTICE, + TELEMETRY_NOTICE_TITLE, } from "../telemetry"; import { CLIError } from "../util/cli-error"; import { buildInvocation } from "../util/invocation"; @@ -124,20 +125,34 @@ export async function runWizard( return finish({ status: "cancelled" }); } + // The migration's start opens a step; its summary, and the launcher + // lines below, continue under it rather than each opening one of their + // own, so one install reads as one block. + let migrationStarted = false; const migrated = await ensureTasklessDirectory(options.cwd, { - onNotice: (message) => log.info(message), + onNotice: (message) => { + if (migrationStarted) { + log.message(message, { spacing: 0 }); + } else { + log.info(message); + migrationStarted = true; + } + }, }); const cliVersion = getCliVersion(); const { launcher, lockfile } = await applyInstallPlan(options.cwd, plan, { cliVersion, }); + const created: string[] = []; for (const write of [launcher, lockfile]) { - if (write !== null && write.state !== "unchanged") { - (write.state === "restored" ? log.warn : log.info)( - describeLauncherWrite(write) - ); + if (write === null || write.state === "unchanged") continue; + if (write.state === "restored") { + log.warn(describeLauncherWrite(write)); + } else { + created.push(describeLauncherWrite(write)); } } + if (created.length > 0) log.message(created.join("\n")); await syncPermissions(options.cwd, { ...permissionOptions, write: true }); // Confirmed with the project edits, in the summary's one question. if (globalTrustPreview.some((outcome) => needsWrite(outcome))) { @@ -158,19 +173,31 @@ export async function runWizard( } if (await isTelemetryEnabled(options.cwd)) { - log.info(TELEMETRY_NOTICE); + log.info(`${TELEMETRY_NOTICE_TITLE}\n${TELEMETRY_NOTICE}`); } // Inside the frame, after every write: the commit has to carry the // settings edits above as well as the skills. Before the outro, because // the onboarding line that follows sends the person to their agent. - const uncommitted = await findUncommitted( - options.cwd, - await installedPaths(options.cwd, plan) - ); - if (uncommitted.length > 0) await promptCommit(options.cwd, uncommitted); + const owned = await installedPaths(options.cwd, plan); + const uncommitted = await findUncommitted(options.cwd, owned); + // Asked again after the prompt rather than inferred from its answer: a + // cancel, a skip, and a commit git refused all leave the files on disk and + // out of history, and the outro is the one line that has to say so. A + // cancel here used to close on "ready to go", which read as though the + // commit had happened. + let leftUncommitted = false; + if (uncommitted.length > 0) { + await promptCommit(options.cwd, uncommitted); + const remaining = await findUncommitted(options.cwd, owned); + leftUncommitted = remaining.length > 0; + } - outro("Taskless is ready to go."); + outro( + leftUncommitted + ? "Taskless is installed, but its files are not committed yet." + : "Taskless is ready to go." + ); const pinnedNotice = getPinnedCliNotice( await findStalePins(options.cwd, cliVersion), cliVersion, diff --git a/packages/cli/test/feedback-recipes.test.ts b/packages/cli/test/feedback-recipes.test.ts index 5e6f0d66..659180f9 100644 --- a/packages/cli/test/feedback-recipes.test.ts +++ b/packages/cli/test/feedback-recipes.test.ts @@ -180,8 +180,8 @@ describe("the feedback invite", () => { fragment.indexOf("They asked you not to send anything") ); expect(refusal).toContain(`${invocation} feedback dismiss`); - expect(refusal).toContain("DO_NOT_TRACK=1"); expect(refusal).toContain("TASKLESS_TELEMETRY_DISABLED=1"); + expect(refusal).not.toContain("DO_NOT_TRACK"); }); }); diff --git a/packages/cli/test/telemetry.test.ts b/packages/cli/test/telemetry.test.ts index 816e6803..b2f68274 100644 --- a/packages/cli/test/telemetry.test.ts +++ b/packages/cli/test/telemetry.test.ts @@ -168,10 +168,11 @@ describe("telemetry disabled by the project manifest", () => { } ); - it("names every opt-out in the install notice", async () => { + it("names Taskless's own opt-outs in the install notice", async () => { const { TELEMETRY_NOTICE } = await import("../src/telemetry"); expect(TELEMETRY_NOTICE).toContain("TASKLESS_TELEMETRY_DISABLED=1"); - expect(TELEMETRY_NOTICE).toContain("DO_NOT_TRACK=1"); + // Honored, but a cross-tool setting, so not Taskless's to advertise. + expect(TELEMETRY_NOTICE).not.toContain("DO_NOT_TRACK"); expect(TELEMETRY_NOTICE).toContain('"telemetry": false'); expect(TELEMETRY_NOTICE).toContain(".taskless/taskless.json"); }); diff --git a/packages/cli/test/wizard-integration.test.ts b/packages/cli/test/wizard-integration.test.ts index 3367b57a..fd25176d 100644 --- a/packages/cli/test/wizard-integration.test.ts +++ b/packages/cli/test/wizard-integration.test.ts @@ -21,6 +21,7 @@ vi.mock("../src/telemetry", () => ({ isTelemetryEnabled: () => Promise.resolve(telemetryEnabled), // The wording is asserted in telemetry.test.ts; here only that it is shown. TELEMETRY_NOTICE: "", + TELEMETRY_NOTICE_TITLE: "", shutdownTelemetry: () => Promise.resolve(), })); @@ -29,6 +30,8 @@ const fakeCancelSymbol = Symbol("cancel"); // Records what closed the clack frame, so a test can tell "the message was // shown inside the frame" from "it escaped past a frame nothing closed". const cancelSpy = vi.fn(); +// Records how the frame closed on a run that finished. +const outroSpy = vi.fn(); // Clack mock responses are set per-test via these mutable refs. const clackResponses: { @@ -57,7 +60,9 @@ const infoMessages: string[] = []; vi.mock("@clack/prompts", () => ({ intro: () => {}, - outro: () => {}, + outro: (message?: string) => { + outroSpy(message); + }, cancel: (message?: string) => { cancelSpy(message); }, @@ -116,6 +121,7 @@ beforeEach(async () => { await mkdir(join(cwd, ".claude"), { recursive: true }); captureSpy.mockClear(); cancelSpy.mockClear(); + outroSpy.mockClear(); clackResponses.locations = undefined; clackResponses.summary = undefined; clackResponses.commit = undefined; @@ -190,9 +196,9 @@ describe("runWizard end-to-end", () => { const { runWizard } = await import("../src/wizard"); await runWizard({ cwd, globalTrustEnvironment }); - expect(infoMessages.filter((m) => m === "")).toHaveLength( - 1 - ); + expect( + infoMessages.filter((m) => m === "\n") + ).toHaveLength(1); }); it("says nothing about telemetry when it is already off", async () => { @@ -203,7 +209,7 @@ describe("runWizard end-to-end", () => { const { runWizard } = await import("../src/wizard"); await runWizard({ cwd, globalTrustEnvironment }); - expect(infoMessages).not.toContain(""); + expect(infoMessages.join("\n")).not.toContain(""); }); it("re-running with the same location is idempotent", async () => { @@ -704,6 +710,7 @@ describe("the wizard's offer to commit the install", () => { expect(git(cwd, "log", "-1", "--format=%s").trim()).toBe( "chore: Installs Taskless and sets up coding agent support" ); + expect(outroSpy).toHaveBeenCalledWith("Taskless is ready to go."); const committed = git(cwd, "show", "--name-only", "--format=", "HEAD"); expect(committed).toContain(".taskless/taskless.json"); expect(committed).toContain(".claude/skills/taskless/SKILL.md"); @@ -772,6 +779,10 @@ describe("the wizard's offer to commit the install", () => { expect(result.status).toBe("completed"); expect(cancelSpy).not.toHaveBeenCalled(); expect(git(cwd, "status", "--porcelain")).toContain("?? .taskless/"); + // Closes on what is true of the tree, not on "ready to go". + expect(outroSpy).toHaveBeenCalledWith( + "Taskless is installed, but its files are not committed yet." + ); }); it("does not ask once everything is committed", async () => {