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
9 changes: 9 additions & 0 deletions .changeset/wizard-grouped-output.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion openspec/specs/cli-feedback-survey/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion openspec/specs/cli-init/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 1 addition & 3 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/agent/feedback-invite.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/src/agent/init.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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.

Expand Down
7 changes: 5 additions & 2 deletions packages/cli/src/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,13 @@ export async function isTelemetryEnabled(cwd?: string): Promise<boolean> {
* 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() {},
Expand Down
51 changes: 39 additions & 12 deletions packages/cli/src/wizard/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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))) {
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/test/feedback-recipes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});

Expand Down
5 changes: 3 additions & 2 deletions packages/cli/test/telemetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
Expand Down
21 changes: 16 additions & 5 deletions packages/cli/test/wizard-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>",
TELEMETRY_NOTICE_TITLE: "<telemetry title>",
shutdownTelemetry: () => Promise.resolve(),
}));

Expand All @@ -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: {
Expand Down Expand Up @@ -57,7 +60,9 @@ const infoMessages: string[] = [];

vi.mock("@clack/prompts", () => ({
intro: () => {},
outro: () => {},
outro: (message?: string) => {
outroSpy(message);
},
cancel: (message?: string) => {
cancelSpy(message);
},
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -190,9 +196,9 @@ describe("runWizard end-to-end", () => {
const { runWizard } = await import("../src/wizard");
await runWizard({ cwd, globalTrustEnvironment });

expect(infoMessages.filter((m) => m === "<telemetry notice>")).toHaveLength(
1
);
expect(
infoMessages.filter((m) => m === "<telemetry title>\n<telemetry notice>")
).toHaveLength(1);
});

it("says nothing about telemetry when it is already off", async () => {
Expand All @@ -203,7 +209,7 @@ describe("runWizard end-to-end", () => {
const { runWizard } = await import("../src/wizard");
await runWizard({ cwd, globalTrustEnvironment });

expect(infoMessages).not.toContain("<telemetry notice>");
expect(infoMessages.join("\n")).not.toContain("<telemetry notice>");
});

it("re-running with the same location is idempotent", async () => {
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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 () => {
Expand Down
Loading