From 04a6c5f8750d9f7c8f8444168651f7986696777b Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Fri, 2 Oct 2026 11:03:49 -0700 Subject: [PATCH] feat(cli): add taskless share, a QR code for taskless.io --- .changeset/share-command.md | 5 ++ .../no-unrouted-cli-invocation.yml | 5 ++ packages/cli/package.json | 1 + packages/cli/src/commands/names.ts | 1 + packages/cli/src/commands/share.ts | 90 +++++++++++++++++++ packages/cli/src/index.ts | 2 + packages/cli/src/wizard/intro.ts | 15 +++- pnpm-lock.yaml | 8 ++ 8 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 .changeset/share-command.md create mode 100644 packages/cli/src/commands/share.ts diff --git a/.changeset/share-command.md b/.changeset/share-command.md new file mode 100644 index 00000000..5c3bdc19 --- /dev/null +++ b/.changeset/share-command.md @@ -0,0 +1,5 @@ +--- +"@taskless/cli": patch +--- + +`taskless share` prints the Taskless banner over a scannable QR code for taskless.io, with `npx @taskless/cli` beneath it, for showing Taskless to someone in person. The link carries `utm_source=cli`, `utm_medium=share`, and the CLI's base version as `utm_content`, so a visit is credited to the command and to the release that sent it. `--json` prints the URL instead. diff --git a/.taskless/rules/sg/no-unrouted-cli-invocation/no-unrouted-cli-invocation.yml b/.taskless/rules/sg/no-unrouted-cli-invocation/no-unrouted-cli-invocation.yml index 13ea158f..2c86e72e 100644 --- a/.taskless/rules/sg/no-unrouted-cli-invocation/no-unrouted-cli-invocation.yml +++ b/.taskless/rules/sg/no-unrouted-cli-invocation/no-unrouted-cli-invocation.yml @@ -64,6 +64,11 @@ ignores: # and makes the next one a decision rather than an inheritance. - "packages/cli/src/schemas/verify-test.ts" - "packages/cli/src/schemas/index.ts" + # `share` prints the invocation for SOMEONE ELSE to run, beside a QR code + # they scan. A nightly or local build advertising its own pinned launcher + # would send that person to a prerelease, so the release spelling is + # deliberate here on every build target. + - "packages/cli/src/commands/share.ts" rule: kind: string_fragment regex: "@taskless/cli" diff --git a/packages/cli/package.json b/packages/cli/package.json index 49798317..3f2ed906 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -66,6 +66,7 @@ "smol-toml": "^1.6.1", "sprintf-js": "^1.1.3", "tsx": "^4.21.0", + "uqr": "^0.1.3", "yaml": "^2.8.2", "zod": "^4.3.6" }, diff --git a/packages/cli/src/commands/names.ts b/packages/cli/src/commands/names.ts index f90956e7..89b5d0e4 100644 --- a/packages/cli/src/commands/names.ts +++ b/packages/cli/src/commands/names.ts @@ -20,6 +20,7 @@ export const SUBCOMMAND_NAMES = [ "init", "onboard", "rule", + "share", "test", "update", "verify", diff --git a/packages/cli/src/commands/share.ts b/packages/cli/src/commands/share.ts new file mode 100644 index 00000000..9f58ce21 --- /dev/null +++ b/packages/cli/src/commands/share.ts @@ -0,0 +1,90 @@ +import process from "node:process"; + +import chalk from "chalk"; +import { defineCommand } from "citty"; +import { encode, renderUnicodeCompact } from "uqr"; + +import "../util/color"; +import { CLI_VERSION } from "../version"; +import { BANNER_WIDTH, renderBanner } from "../wizard/intro"; + +/** + * The bare minimum to attribute a visit: `source`/`medium` credit the share + * command, `content` carries the base version with any prerelease or build + * suffix cut, so every nightly of a release counts toward that release. + */ +const SHARE_URL = `https://www.taskless.io/?${new URLSearchParams({ + utm_source: "cli", + utm_medium: "share", + utm_content: CLI_VERSION.split(/[-+]/)[0] ?? CLI_VERSION, +}).toString()}`; + +/** + * The prod invocation, spelled out rather than taken from `buildInvocation()`: + * a nightly or local build would otherwise share its own pinned launcher, + * which is not what anyone being shown the code should run. + */ +const SHARE_CAPTION = "`npx @taskless/cli` // www.taskless.io"; + +/** Left-pad a block of uncolored text to center it under the banner. */ +function center( + block: string, + style: (line: string) => string = (line) => line +): string { + return block + .split("\n") + .map((line) => { + const pad = Math.max(0, Math.floor((BANNER_WIDTH - line.length) / 2)); + return " ".repeat(pad) + style(line); + }) + .join("\n"); +} + +/** + * Two modules per line with half blocks, drawn dark-on-white with explicit + * colors so the code reads the same on a light or a dark terminal theme. + */ +function renderColored(url: string): string { + const { data } = encode(url, { ecc: "L", border: 2 }); + const lines: string[] = []; + for (let y = 0; y < data.length; y += 2) { + const topRow = data[y] ?? []; + const bottomRow = data[y + 1] ?? []; + const line = topRow.map((top, x) => { + const bottom = bottomRow[x] ?? false; + return top && bottom ? "█" : top ? "▀" : bottom ? "▄" : " "; + }); + lines.push(line.join("")); + } + return center(lines.join("\n"), (line) => chalk.bgWhiteBright.black(line)); +} + +export const shareCommand = defineCommand({ + meta: { + name: "share", + description: "Show a QR code for taskless.io", + }, + args: { + json: { + type: "boolean", + description: "Output as JSON", + default: false, + }, + }, + run({ args }) { + if (args.json) { + console.log(JSON.stringify({ url: SHARE_URL })); + return; + } + + // Without color the half blocks fall back to the terminal's own + // foreground, which assumes a dark theme; better than no code at all. + const qr = + chalk.level > 0 + ? renderColored(SHARE_URL) + : center(renderUnicodeCompact(SHARE_URL, { ecc: "L", border: 2 })); + const caption = center(SHARE_CAPTION); + + process.stdout.write(`\n${renderBanner()}\n\n${qr}\n\n\n${caption}\n\n`); + }, +}); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index cb030443..71b5a371 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -14,6 +14,7 @@ import { infoCommand } from "./commands/info"; import { type SubcommandName } from "./commands/names"; import { onboardCommand } from "./commands/onboard"; import { ruleCommand } from "./commands/rules"; +import { shareCommand } from "./commands/share"; import { getTelemetry, resolveRunIdentity, @@ -47,6 +48,7 @@ const subCommands = { auth: authCommand, onboard: onboardCommand, rule: ruleCommand, + share: shareCommand, verify: verifyCommand, test: testCommand, } satisfies Record, unknown>; diff --git a/packages/cli/src/wizard/intro.ts b/packages/cli/src/wizard/intro.ts index 2f429336..1e891bdf 100644 --- a/packages/cli/src/wizard/intro.ts +++ b/packages/cli/src/wizard/intro.ts @@ -28,15 +28,24 @@ const BANNER = ` * non-TTY contexts via chalk's built-in detection. */ export function renderIntro(): string { + const version = chalk.dim(`v${CLI_VERSION}`); + return `${renderBanner()}\n${version}`; +} + +/** The wordmark alone, colored, for callers that lay out their own content. */ +export function renderBanner(): string { // Direct hex instead of chalk.dim(chalk.cyan(…)): the composed form // emits reset codes at each newline and the reapplication flashes // brighter on some quadrant chars. A single truecolor attribute // stays flat across the whole banner. - const coloredBanner = chalk.hex("#2B7384")(BANNER); - const version = chalk.dim(`v${CLI_VERSION}`); - return `${coloredBanner}\n${version}`; + return chalk.hex("#2B7384")(BANNER); } +/** Columns the widest banner line occupies; every glyph in it is one column. */ +export const BANNER_WIDTH = Math.max( + ...BANNER.split("\n").map((line) => line.length) +); + export function getCliVersion(): string { return CLI_VERSION; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 25af24de..09a4119c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -107,6 +107,9 @@ importers: tsx: specifier: ^4.21.0 version: 4.21.0 + uqr: + specifier: ^0.1.3 + version: 0.1.3 yaml: specifier: ^2.8.2 version: 2.8.2 @@ -2542,6 +2545,9 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + uqr@0.1.3: + resolution: {integrity: sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA==} + uri-js-replace@1.0.1: resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==} @@ -4922,6 +4928,8 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + uqr@0.1.3: {} + uri-js-replace@1.0.1: {} uri-js@4.4.1: