From 9171ef72f4abb38b5ac4f797976a621b9a8f9327 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Tue, 19 May 2026 15:04:13 +0200 Subject: [PATCH] feat(cli): style next-actions note in TTY mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add createAgentNextActionsLineFormatter, an ANSI line transformer wired into the "Required before using agents" Clack note. It activates only when the target stream reports hasColors(), so non-TTY pipelines and tests keep the existing plain-text output byte-identical. Per-line rules: cyan-bold step numbers + bold titles; dim sub-prose aligned under the title; dim-cyan bullet for .zip paths with HOME shortened to ~; dim "›" replaces " > " breadcrumbs; RUN/PASTE/USE/OPEN markers dimmed; already-styled lines pass through to avoid double-wrap. --- packages/cli/src/setup-agents.test.ts | 60 +++++++++++++++++++++++++++ packages/cli/src/setup-agents.ts | 55 +++++++++++++++++++++++- 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/setup-agents.test.ts b/packages/cli/src/setup-agents.test.ts index 4975e487..07459713 100644 --- a/packages/cli/src/setup-agents.test.ts +++ b/packages/cli/src/setup-agents.test.ts @@ -5,6 +5,7 @@ import { readKtxSetupState } from '@ktx/context/project'; import { strFromU8, unzipSync } from 'fflate'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { + createAgentNextActionsLineFormatter, formatInstallSummaryLines, plannedKtxAgentFiles, readKtxAgentInstallManifest, @@ -1169,4 +1170,63 @@ describe('setup agents', () => { expect(output).toContain('OpenCode commands installed'); expect(output).toContain('.agents guidance installed'); }); + + describe('createAgentNextActionsLineFormatter', () => { + function makeColorStdout(): { write: (chunk: string) => boolean; hasColors: () => boolean } { + return { write: () => true, hasColors: () => true }; + } + + function makePlainStdout(): { write: (chunk: string) => boolean; hasColors: () => boolean } { + return { write: () => true, hasColors: () => false }; + } + + const ESC = String.fromCharCode(27); + + it('returns the line untouched when the stream cannot render colors', () => { + const format = createAgentNextActionsLineFormatter(makePlainStdout()); + expect(format('2. Upload Claude Desktop skills')).toBe('2. Upload Claude Desktop skills'); + expect(format(' /tmp/ktx/.ktx/agents/claude/ktx.zip')).toBe(' /tmp/ktx/.ktx/agents/claude/ktx.zip'); + }); + + it('styles step headings and aligns sub-prose under the title', () => { + const format = createAgentNextActionsLineFormatter(makeColorStdout()); + const heading = format('2. Upload Claude Desktop skills'); + expect(heading).toContain(ESC); + expect(heading).toContain('2'); + expect(heading).toContain('Upload Claude Desktop skills'); + expect(heading).not.toMatch(/^2\. /); + + const sub = format(' Toggle the uploaded KTX skills on.'); + expect(sub).toMatch(/^ {3}/); + expect(sub).toContain('Toggle the uploaded KTX skills on.'); + }); + + it('renders skill bundle .zip paths as bullets and shortens HOME to ~', () => { + const previousHome = process.env.HOME; + process.env.HOME = '/tmp/test-home'; + try { + const format = createAgentNextActionsLineFormatter(makeColorStdout()); + const line = format(' /tmp/test-home/.ktx/agents/claude/ktx-analytics.zip'); + expect(line).toContain('•'); + expect(line).toContain('~/.ktx/agents/claude/ktx-analytics.zip'); + expect(line).not.toContain('/tmp/test-home/'); + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + } + }); + + it('replaces breadcrumb separators with a typographic chevron', () => { + const format = createAgentNextActionsLineFormatter(makeColorStdout()); + const line = format(' Open Claude Desktop: Customize > Skills > + > Create skill > Upload a skill.'); + expect(line).toContain('›'); + expect(line).not.toContain(' > '); + }); + + it('leaves already-styled lines untouched to avoid double-wrapping', () => { + const format = createAgentNextActionsLineFormatter(makeColorStdout()); + const preStyled = `${ESC}[1m2. Already styled${ESC}[22m`; + expect(format(preStyled)).toBe(preStyled); + }); + }); }); diff --git a/packages/cli/src/setup-agents.ts b/packages/cli/src/setup-agents.ts index b7389211..f1e40638 100644 --- a/packages/cli/src/setup-agents.ts +++ b/packages/cli/src/setup-agents.ts @@ -3,6 +3,7 @@ import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { dirname, join, relative, resolve } from 'node:path'; import type { Writable } from 'node:stream'; import { fileURLToPath } from 'node:url'; +import { styleText } from 'node:util'; import { log, outro } from '@clack/prompts'; import { loadKtxProject, @@ -114,6 +115,56 @@ function writeSetupOutro(io: KtxCliIo, message: string): void { io.stdout.write(`\n${message}\n`); } +const STEP_HEADING_RE = /^(\d+)\. (.+)$/; +const ACTION_MARKER_RE = /^(RUN|PASTE|USE|OPEN):$/; + +export function createAgentNextActionsLineFormatter( + stdout: KtxCliIo['stdout'], +): (line: string) => string { + const maybeHasColors = (stdout as { hasColors?: unknown }).hasColors; + const supportsColor = typeof maybeHasColors === 'function' && Boolean(maybeHasColors.call(stdout)); + if (!supportsColor) return (line) => line; + + const homeDir = process.env.HOME ? resolve(process.env.HOME) : ''; + const styleOptions = { validateStream: false } as const; + const dim = (s: string) => styleText('dim', s, styleOptions); + const bold = (s: string) => styleText('bold', s, styleOptions); + const cyanBold = (s: string) => styleText(['cyan', 'bold'], s, styleOptions); + const dimCyan = (s: string) => styleText(['dim', 'cyan'], s, styleOptions); + const shortenPath = (path: string): string => { + if (!homeDir) return path; + if (path === homeDir) return '~'; + if (path.startsWith(`${homeDir}/`)) return `~/${path.slice(homeDir.length + 1)}`; + return path; + }; + + return (rawLine: string): string => { + if (rawLine.length === 0 || rawLine.includes('[')) return rawLine; + + const heading = rawLine.match(STEP_HEADING_RE); + if (heading) { + return `${cyanBold(heading[1])} ${bold(heading[2])}`; + } + + if (!rawLine.startsWith(' ')) return rawLine; + const body = rawLine.slice(2); + + if (ACTION_MARKER_RE.test(body)) { + return ` ${dim(body)}`; + } + + if (body.endsWith('.zip') && (body.startsWith('/') || body.startsWith('~'))) { + return ` ${dimCyan('•')} ${shortenPath(body)}`; + } + + if (body.includes(' > ')) { + return ` ${body.replaceAll(' > ', ` ${dim('›')} `)}`; + } + + return ` ${dim(body)}`; + }; +} + async function readJsonObject(path: string): Promise> { if (!existsSync(path)) return {}; const parsed = JSON.parse(await readFile(path, 'utf-8')) as unknown; @@ -1235,7 +1286,9 @@ export async function runKtxSetupAgentsStep( snippets, }); if (args.showNextActions !== false) { - setupUi.note(nextActions, 'Required before using agents', io, { format: (line) => line }); + setupUi.note(nextActions, 'Required before using agents', io, { + format: createAgentNextActionsLineFormatter(io.stdout), + }); writeSetupOutro(io, 'All set.'); } return { status: 'ready', projectDir: args.projectDir, installs, nextActions };