diff --git a/packages/cli/src/cli-program.ts b/packages/cli/src/cli-program.ts index 6359d897..07010f07 100644 --- a/packages/cli/src/cli-program.ts +++ b/packages/cli/src/cli-program.ts @@ -2,6 +2,7 @@ import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { Command, type CommandUnknownOpts, InvalidArgumentError } from '@commander-js/extra-typings'; import type { KtxCliDeps, KtxCliIo, KtxCliPackageInfo } from './cli-runtime.js'; +import { SLACK_HELP_FOOTER, writeErrorCommunityHint } from './community-cta.js'; import { registerCompletionCommands } from './commands/completion-commands.js'; import { registerConnectionCommands } from './commands/connection-commands.js'; import { registerIngestCommands } from './commands/ingest-commands.js'; @@ -258,6 +259,7 @@ function createBaseProgram(info: KtxCliPackageInfo, io: KtxCliIo): Command { .helpOption('-h, --help', 'Show this help text') .configureHelp({ showGlobalOptions: true }) .showHelpAfterError() + .addHelpText('after', `\n${SLACK_HELP_FOOTER}`) .exitOverride() .configureOutput({ writeOut: (chunk) => io.stdout.write(chunk), @@ -561,6 +563,7 @@ export async function runCommanderKtxCli( io, }); io.stderr.write(`${formatCliError(error)}\n`); + writeErrorCommunityHint(io, 'error'); return 1; } } @@ -585,6 +588,7 @@ export async function runCommanderKtxCli( exitCode = error.exitCode === 0 ? 0 : 1; } else { io.stderr.write(`${formatCliError(error)}\n`); + writeErrorCommunityHint(io, 'error'); exitCode = 1; } } finally { diff --git a/packages/cli/src/cli-runtime.ts b/packages/cli/src/cli-runtime.ts index 4e13b472..69416006 100644 --- a/packages/cli/src/cli-runtime.ts +++ b/packages/cli/src/cli-runtime.ts @@ -12,6 +12,7 @@ import type { KtxSqlArgs } from './sql.js'; import { profileMark, profileSpan } from './startup-profile.js'; import type { KtxTextIngestArgs } from './text-ingest.js'; import { assertCliVersion } from './release-version.js'; +import { writeErrorCommunityHint } from './community-cta.js'; profileMark('module:cli-runtime'); @@ -144,6 +145,16 @@ export function createGlobalExceptionReporter(io: KtxCliIo, info: KtxCliPackageI }; } +/** @internal */ +export function writeGlobalExceptionToStderr(io: KtxCliIo, error: unknown): void { + if (error instanceof Error && error.stack) { + io.stderr.write(`${error.stack}\n`); + } else { + io.stderr.write(`${String(error)}\n`); + } + writeErrorCommunityHint(io, 'crash'); +} + export function installGlobalExceptionHandlers(io: KtxCliIo, info: KtxCliPackageInfo): () => void { const report = createGlobalExceptionReporter(io, info); const handle = (source: 'uncaughtException' | 'unhandledRejection', error: unknown): void => { @@ -153,11 +164,7 @@ export function installGlobalExceptionHandlers(io: KtxCliIo, info: KtxCliPackage } catch { // Best-effort: preserve Node's process termination behavior. } - if (error instanceof Error && error.stack) { - io.stderr.write(`${error.stack}\n`); - } else { - io.stderr.write(`${String(error)}\n`); - } + writeGlobalExceptionToStderr(io, error); process.exit(1); })(); }; diff --git a/packages/cli/src/community-cta.ts b/packages/cli/src/community-cta.ts new file mode 100644 index 00000000..b4702542 --- /dev/null +++ b/packages/cli/src/community-cta.ts @@ -0,0 +1,28 @@ +import type { KtxCliIo } from './cli-runtime.js'; +import { isWritableTtyOutput } from './io/tty.js'; +import { dim } from './io/symbols.js'; +import { SLACK_URL } from './links.js'; + +type ErrorCtaVariant = 'error' | 'crash'; + +/** @internal */ +export const SLACK_HELP_FOOTER = `Community & support: ${SLACK_URL}`; + +/** @internal */ +export const SLACK_SETUP_NOTE = { + title: 'Community', + body: `Questions or feedback? Join the ktx Slack: ${SLACK_URL}`, +} as const; + +export function writeErrorCommunityHint(io: KtxCliIo, variant: ErrorCtaVariant): void { + if (!isWritableTtyOutput(io.stderr)) { + return; + } + + const line = + variant === 'crash' + ? `This may be a bug - report it or ask in the ktx community: ${SLACK_URL}` + : `Stuck? The ktx community can help: ${SLACK_URL}`; + + io.stderr.write(`${dim(line)}\n`); +} diff --git a/packages/cli/src/io/tty.ts b/packages/cli/src/io/tty.ts new file mode 100644 index 00000000..43467d4e --- /dev/null +++ b/packages/cli/src/io/tty.ts @@ -0,0 +1,17 @@ +import type { Writable } from 'node:stream'; + +import type { KtxCliIo } from '../cli-runtime.js'; + +type KtxCliOutput = (KtxCliIo['stdout'] | KtxCliIo['stderr']) & { + isTTY?: boolean; + columns?: number; + on?: unknown; +}; + +export function isWritableTtyOutput(output: KtxCliOutput): output is KtxCliOutput & Writable { + return ( + (output as { isTTY?: unknown }).isTTY === true && + typeof (output as { on?: unknown }).on === 'function' && + typeof (output as { columns?: unknown }).columns !== 'undefined' + ); +} diff --git a/packages/cli/src/links.ts b/packages/cli/src/links.ts new file mode 100644 index 00000000..1ca62c59 --- /dev/null +++ b/packages/cli/src/links.ts @@ -0,0 +1 @@ +export const SLACK_URL = 'https://ktx.sh/slack'; diff --git a/packages/cli/src/setup-agents.ts b/packages/cli/src/setup-agents.ts index a671ba4b..3c7e5bad 100644 --- a/packages/cli/src/setup-agents.ts +++ b/packages/cli/src/setup-agents.ts @@ -1,7 +1,6 @@ import { existsSync } from 'node:fs'; import { 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'; @@ -11,6 +10,7 @@ import { serializeKtxProjectConfig } from './context/project/config.js'; import { strToU8, zipSync } from 'fflate'; import type { KtxCliIo } from './cli-runtime.js'; import { errorMessage, writePrefixedLines } from './clack.js'; +import { isWritableTtyOutput } from './io/tty.js'; import { createKtxSetupPromptAdapter, createKtxSetupUiAdapter, @@ -84,14 +84,6 @@ interface KtxCliLauncher { args: string[]; } -function isWritableTtyOutput(output: KtxCliIo['stdout']): output is KtxCliIo['stdout'] & Writable { - return ( - output.isTTY === true && - typeof (output as { on?: unknown }).on === 'function' && - typeof (output as { columns?: unknown }).columns !== 'undefined' - ); -} - function writeSetupInfo(io: KtxCliIo, message: string): void { if (isWritableTtyOutput(io.stdout)) { log.info(message, { output: io.stdout }); diff --git a/packages/cli/src/setup-prompts.ts b/packages/cli/src/setup-prompts.ts index e508d8ff..e135004f 100644 --- a/packages/cli/src/setup-prompts.ts +++ b/packages/cli/src/setup-prompts.ts @@ -1,4 +1,3 @@ -import type { Writable } from 'node:stream'; import { autocomplete, autocompleteMultiselect, @@ -13,6 +12,7 @@ import { text, } from '@clack/prompts'; import type { KtxCliIo } from './cli-runtime.js'; +import { isWritableTtyOutput } from './io/tty.js'; import { withMenuOptionsSpacing, withTextInputNavigation } from './prompt-navigation.js'; import { revealPassword } from './reveal-password-prompt.js'; import { withSetupInterruptConfirmation } from './setup-interrupt.js'; @@ -211,14 +211,6 @@ export interface KtxSetupUiAdapter { note(message: string, title: string, io: KtxCliIo, options?: KtxSetupNoteOptions): void; } -function isWritableTtyOutput(output: KtxCliIo['stdout']): output is KtxCliIo['stdout'] & Writable { - return ( - output.isTTY === true && - typeof (output as { on?: unknown }).on === 'function' && - typeof (output as { columns?: unknown }).columns !== 'undefined' - ); -} - export function createKtxSetupUiAdapter(): KtxSetupUiAdapter { return { intro(title, io) { diff --git a/packages/cli/src/setup.ts b/packages/cli/src/setup.ts index 9539a248..2ff58dbf 100644 --- a/packages/cli/src/setup.ts +++ b/packages/cli/src/setup.ts @@ -6,6 +6,7 @@ import { ktxLocalStateDbPath } from './context/project/local-state-db.js'; import { loadKtxProject, type KtxLocalProject } from './context/project/project.js'; import { readKtxSetupState } from './context/project/setup-config.js'; import { getKtxCliPackageInfo, type KtxCliIo } from './cli-runtime.js'; +import { SLACK_SETUP_NOTE } from './community-cta.js'; import { formatNextStepLines, formatSetupNextStepLines } from './next-steps.js'; import { runtimeInstallPolicyFromFlags } from './managed-python-command.js'; import { readManagedPythonRuntimeStatus } from './managed-python-runtime.js'; @@ -921,5 +922,6 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup ); } } + setupUi.note(SLACK_SETUP_NOTE.body, SLACK_SETUP_NOTE.title, io); return 0; } diff --git a/packages/cli/test/cli-program-telemetry.test.ts b/packages/cli/test/cli-program-telemetry.test.ts index 30e2bd2b..fb624c66 100644 --- a/packages/cli/test/cli-program-telemetry.test.ts +++ b/packages/cli/test/cli-program-telemetry.test.ts @@ -13,9 +13,27 @@ vi.mock('../src/telemetry/exception.js', () => ({ reportException: reportExceptionMock, })); -function makeIo(stdoutIsTTY = true): { io: KtxCliIo; stdout: () => string; stderr: () => string } { +function makeIo( + stdoutIsTTY = true, + stderrIsTTY = false, +): { io: KtxCliIo; stdout: () => string; stderr: () => string } { let stdout = ''; let stderr = ''; + const stderrStream = stderrIsTTY + ? { + isTTY: true, + columns: 80, + on: () => undefined, + write: (chunk: string) => { + stderr += chunk; + }, + } + : { + write: (chunk: string) => { + stderr += chunk; + }, + }; + return { io: { stdout: { @@ -24,11 +42,7 @@ function makeIo(stdoutIsTTY = true): { io: KtxCliIo; stdout: () => string; stder stdout += chunk; }, }, - stderr: { - write: (chunk) => { - stderr += chunk; - }, - }, + stderr: stderrStream, }, stdout: () => stdout, stderr: () => stderr, @@ -164,4 +178,75 @@ describe('runCommanderKtxCli telemetry', () => { }), ); }); + + it('prints the Slack hint for unexpected command errors on TTY stderr only', async () => { + const ttyIo = makeIo(true, true); + const deps: KtxCliDeps = { + doctor: async () => { + throw new Error('status failed'); + }, + }; + + await expect( + runCommanderKtxCli( + ['--project-dir', tempDir, 'status', '--json'], + ttyIo.io, + deps, + info, + { runInit: async () => 0 }, + ), + ).resolves.toBe(1); + + expect(ttyIo.stderr()).toContain('status failed'); + expect(ttyIo.stderr()).toContain('Stuck? The ktx community can help'); + expect(ttyIo.stderr()).toContain('https://ktx.sh/slack'); + + const pipeIo = makeIo(true, false); + await expect( + runCommanderKtxCli( + ['--project-dir', tempDir, 'status', '--json'], + pipeIo.io, + deps, + info, + { runInit: async () => 0 }, + ), + ).resolves.toBe(1); + + expect(pipeIo.stderr()).toContain('status failed'); + expect(pipeIo.stderr()).not.toContain('https://ktx.sh/slack'); + }); + + it('does not print the Slack hint for Commander usage errors', async () => { + const io = makeIo(true, true); + + await expect( + runCommanderKtxCli(['--not-a-real-option'], io.io, {}, info, { runInit: async () => 0 }), + ).resolves.toBe(1); + + expect(io.stderr()).toContain("unknown option '--not-a-real-option'"); + expect(io.stderr()).not.toContain('Stuck? The ktx community can help'); + }); + + it('prints the Slack hint for bare interactive setup failures on TTY stderr', async () => { + const originalCwd = process.cwd(); + const noProjectDir = await mkdtemp(join(tmpdir(), 'ktx-cli-bare-')); + const io = makeIo(true, true); + const deps: KtxCliDeps = { + setup: async () => { + throw new Error('setup failed'); + }, + }; + + try { + process.chdir(noProjectDir); + await expect(runCommanderKtxCli([], io.io, deps, info, { runInit: async () => 0 })).resolves.toBe(1); + } finally { + process.chdir(originalCwd); + await rm(noProjectDir, { recursive: true, force: true }); + } + + expect(io.stderr()).toContain('setup failed'); + expect(io.stderr()).toContain('Stuck? The ktx community can help'); + expect(io.stderr()).toContain('https://ktx.sh/slack'); + }); }); diff --git a/packages/cli/test/cli-program.test.ts b/packages/cli/test/cli-program.test.ts index 332645aa..39c9955c 100644 --- a/packages/cli/test/cli-program.test.ts +++ b/packages/cli/test/cli-program.test.ts @@ -54,6 +54,32 @@ describe('buildKtxProgram', () => { expect(wrote).toBe(''); }); + + it('adds the Slack community footer to root help', () => { + let stdout = ''; + const io: KtxCliIo = { + stdout: { + isTTY: false, + columns: 80, + write: (chunk) => { + stdout += chunk; + }, + }, + stderr: { + write: () => undefined, + }, + }; + const program: Command = buildKtxProgram({ + io, + deps: {}, + packageInfo: stubPackageInfo(), + runInit: async () => 0, + }); + + program.outputHelp(); + + expect(stdout).toContain('Community & support: https://ktx.sh/slack'); + }); }); describe('collectCommandFlagsPresent', () => { diff --git a/packages/cli/test/cli-runtime.test.ts b/packages/cli/test/cli-runtime.test.ts new file mode 100644 index 00000000..96eb23ff --- /dev/null +++ b/packages/cli/test/cli-runtime.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; + +import type { KtxCliIo } from '../src/cli-runtime.js'; +import { writeGlobalExceptionToStderr } from '../src/cli-runtime.js'; + +function makeIo(stderrIsTty: boolean): { io: KtxCliIo; stderr: () => string } { + let stderr = ''; + const stderrStream = stderrIsTty + ? { + isTTY: true, + columns: 80, + on: () => undefined, + write: (chunk: string) => { + stderr += chunk; + }, + } + : { + write: (chunk: string) => { + stderr += chunk; + }, + }; + + return { + io: { + stdout: { + write: () => undefined, + }, + stderr: stderrStream, + }, + stderr: () => stderr, + }; +} + +describe('writeGlobalExceptionToStderr', () => { + it('prints the crash Slack hint after a stack on TTY stderr', () => { + const testIo = makeIo(true); + + writeGlobalExceptionToStderr(testIo.io, new Error('global boom')); + + expect(testIo.stderr()).toContain('Error: global boom'); + expect(testIo.stderr()).toContain('This may be a bug'); + expect(testIo.stderr()).toContain('https://ktx.sh/slack'); + }); + + it('prints crash details without the Slack hint on non-TTY stderr', () => { + const testIo = makeIo(false); + + writeGlobalExceptionToStderr(testIo.io, 'global boom'); + + expect(testIo.stderr()).toContain('global boom'); + expect(testIo.stderr()).not.toContain('https://ktx.sh/slack'); + }); +}); diff --git a/packages/cli/test/community-cta.test.ts b/packages/cli/test/community-cta.test.ts new file mode 100644 index 00000000..7d21e77e --- /dev/null +++ b/packages/cli/test/community-cta.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; + +import { + SLACK_HELP_FOOTER, + SLACK_SETUP_NOTE, + writeErrorCommunityHint, +} from '../src/community-cta.js'; +import type { KtxCliIo } from '../src/cli-runtime.js'; + +function makeIo(stderrIsTty: boolean): { io: KtxCliIo; stderr: () => string } { + let stderr = ''; + const stderrStream = stderrIsTty + ? { + isTTY: true, + columns: 80, + on: () => undefined, + write: (chunk: string) => { + stderr += chunk; + }, + } + : { + write: (chunk: string) => { + stderr += chunk; + }, + }; + + return { + io: { + stdout: { + write: () => undefined, + }, + stderr: stderrStream, + }, + stderr: () => stderr, + }; +} + +describe('community CTA', () => { + it('writes the error hint to TTY stderr', () => { + const testIo = makeIo(true); + + writeErrorCommunityHint(testIo.io, 'error'); + + expect(testIo.stderr()).toContain('Stuck? The ktx community can help'); + expect(testIo.stderr()).toContain('https://ktx.sh/slack'); + }); + + it('suppresses the error hint for non-TTY stderr', () => { + const testIo = makeIo(false); + + writeErrorCommunityHint(testIo.io, 'error'); + + expect(testIo.stderr()).toBe(''); + }); + + it('uses stronger crash copy for crash hints', () => { + const testIo = makeIo(true); + + writeErrorCommunityHint(testIo.io, 'crash'); + + expect(testIo.stderr()).toContain('This may be a bug'); + expect(testIo.stderr()).toContain('https://ktx.sh/slack'); + }); + + it('exports setup and help copy with the stable Slack URL', () => { + expect(SLACK_HELP_FOOTER).toBe('Community & support: https://ktx.sh/slack'); + expect(SLACK_SETUP_NOTE).toEqual({ + title: 'Community', + body: 'Questions or feedback? Join the ktx Slack: https://ktx.sh/slack', + }); + }); +}); diff --git a/packages/cli/test/io/tty.test.ts b/packages/cli/test/io/tty.test.ts new file mode 100644 index 00000000..3d5fd2c8 --- /dev/null +++ b/packages/cli/test/io/tty.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; + +import { isWritableTtyOutput } from '../../src/io/tty.js'; + +describe('isWritableTtyOutput', () => { + it('accepts writable TTY-like output', () => { + const output = { + isTTY: true, + columns: 80, + on: () => undefined, + write: () => undefined, + }; + + expect(isWritableTtyOutput(output)).toBe(true); + }); + + it('rejects non-TTY output', () => { + expect(isWritableTtyOutput({ write: () => undefined })).toBe(false); + }); + + it('rejects output missing stream event support', () => { + expect( + isWritableTtyOutput({ + isTTY: true, + columns: 80, + write: () => undefined, + }), + ).toBe(false); + }); + + it('rejects output missing column metadata', () => { + const output = { + isTTY: true, + on: () => undefined, + write: () => undefined, + }; + + expect(isWritableTtyOutput(output)).toBe(false); + }); +}); diff --git a/packages/cli/test/setup.test.ts b/packages/cli/test/setup.test.ts index ecb20520..4d46d08c 100644 --- a/packages/cli/test/setup.test.ts +++ b/packages/cli/test/setup.test.ts @@ -652,6 +652,8 @@ describe('setup status', () => { expect(testIo.stdout()).toContain('ktx setup'); expect(testIo.stdout()).not.toContain('ktx agent context --json'); expect(testIo.stdout()).not.toContain('Optional MCP:'); + expect(testIo.stdout()).toContain('Community:'); + expect(testIo.stdout()).toContain('Questions or feedback? Join the ktx Slack: https://ktx.sh/slack'); expect(testIo.stderr()).toBe(''); });