feat(setup): wizard prompt tweaks and quieter query-history filter output (#259)

Setup wizard flow tweaks:
- Add a reveal-tail password prompt (reveal-password-prompt.ts) that unmasks
  the last few characters of a typed/pasted secret, and wire it into the setup
  prompt adapter in place of clack's password(); adds the @clack/core dep.
- Reorder wizard select options: surface "Paste a key" before the
  environment-variable option across embeddings/models/sources, promote
  Metabase/Notion in the source list, put Git URL before Local path, reorder
  the Notion crawl-mode choices, and relabel the sources "Done" action.

Query-history filter picker output:
- Collapse the per-template parse-failure lines into a single count in the
  setup output and route the full template-id list to --debug stderr.
- Model parse failures as a structured parseFailedTemplateIds field instead of
  warning strings.
- Add a privacy-safe query_history_filter_completed telemetry event
  (counts/enums only), mirrored into the Python daemon schema.
This commit is contained in:
Andrey Avtomonov 2026-06-04 14:11:08 +02:00 committed by GitHub
parent 8eb1cd3e79
commit c2beaf7d55
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 494 additions and 34 deletions

View file

@ -406,6 +406,8 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo
}
const resolvedAgentScope = options.local ? 'local' : options.global ? 'global' : 'project';
const debugEnabled =
((command.optsWithGlobals ? command.optsWithGlobals() : command.opts()) as { debug?: unknown }).debug === true;
await runSetupArgs(context, {
command: 'run',
projectDir: resolveCommandProjectDir(command),
@ -415,6 +417,7 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo
agentScope: resolvedAgentScope,
skipAgents: options.skipAgents === true,
inputMode: options.input === false ? 'disabled' : 'auto',
...(debugEnabled ? { debug: true } : {}),
yes: options.yes === true,
cliVersion: context.packageInfo.version,
...(options.llmBackend ? { llmBackend: options.llmBackend } : {}),

View file

@ -23,6 +23,7 @@ export interface QueryHistoryFilterProposal {
consideredRoleCount: number;
skipped: { reason: 'no-llm' | 'no-daemon' | 'no-in-scope-history' | 'user-block-present' } | null;
warnings: string[];
parseFailedTemplateIds: string[];
}
export interface ProposeQueryHistoryServiceAccountFiltersInput {
@ -74,7 +75,7 @@ const queryHistoryFilterAdjudicationSchema = z.object({
type QueryHistoryFilterAdjudication = z.infer<typeof queryHistoryFilterAdjudicationSchema>;
function emptyProposal(skipped: QueryHistoryFilterProposal['skipped'], warnings: string[] = []): QueryHistoryFilterProposal {
return { excludedRoles: [], consideredRoleCount: 0, skipped, warnings };
return { excludedRoles: [], consideredRoleCount: 0, skipped, warnings, parseFailedTemplateIds: [] };
}
function displayTableRef(ref: KtxTableRef): string {
@ -180,6 +181,7 @@ export async function proposeQueryHistoryServiceAccountFilters(
const windowDays = 'windowDays' in config ? config.windowDays : 90;
const windowStart = new Date(now.getTime() - windowDays * 24 * 60 * 60 * 1000);
const warnings: string[] = [];
const parseFailedTemplateIds: string[] = [];
const snapshot: AggregatedTemplate[] = [];
try {
@ -212,7 +214,7 @@ export async function proposeQueryHistoryServiceAccountFilters(
for (const template of snapshot) {
const parsed = analysis.get(template.templateId);
if (!parsed || parsed.error) {
warnings.push(`query_history_filter_picker_parse_failed:${template.templateId}`);
parseFailedTemplateIds.push(template.templateId);
continue;
}
const tablesTouched = [...new Map(parsed.tablesTouched.map((ref) => [tableRefKey(ref), ref])).values()]
@ -236,6 +238,7 @@ export async function proposeQueryHistoryServiceAccountFilters(
consideredRoleCount: records.length,
skipped: { reason: 'no-in-scope-history' },
warnings,
parseFailedTemplateIds,
};
}
@ -256,6 +259,7 @@ export async function proposeQueryHistoryServiceAccountFilters(
...warnings,
`query_history_filter_picker_llm_failed:${error instanceof Error ? error.message : String(error)}`,
],
parseFailedTemplateIds,
};
}
@ -274,5 +278,6 @@ export async function proposeQueryHistoryServiceAccountFilters(
consideredRoleCount: records.length,
skipped: input.userServiceAccountsPresent ? { reason: 'user-block-present' } : null,
warnings,
parseFailedTemplateIds,
};
}

View file

@ -0,0 +1,93 @@
import { styleText } from 'node:util';
import { PasswordPrompt, type PasswordOptions } from '@clack/core';
import { S_BAR, S_BAR_END, S_PASSWORD_MASK, settings, symbol } from '@clack/prompts';
// How many trailing characters of a pasted secret to leave visible so the user
// can confirm what landed (e.g. `••••••a1b2`). Kept small on purpose.
const REVEAL_TAIL_COUNT = 4;
/**
* Mask every character of `userInput` except the last `tail`, but only reveal the
* tail once the secret is long enough that the hidden portion still dominates
* (`length > tail * 2`). Short secrets stay fully masked so we never expose most
* of a small value. The returned string keeps the same code-unit length as the
* input so clack's cursor slicing in `userInputWithCursor` stays aligned.
*
* @internal
*/
export function maskRevealingTail(userInput: string, maskChar: string, tail: number): string {
const revealLength = userInput.length > tail * 2 ? tail : 0;
const hiddenLength = userInput.length - revealLength;
return maskChar.repeat(hiddenLength) + userInput.slice(hiddenLength);
}
class RevealTailPasswordPrompt extends PasswordPrompt {
readonly #maskChar: string;
readonly #tail: number;
constructor(options: PasswordOptions & { tail: number }) {
super(options);
this.#maskChar = options.mask ?? S_PASSWORD_MASK;
this.#tail = options.tail;
}
override get masked(): string {
return maskRevealingTail(this.userInput, this.#maskChar, this.#tail);
}
}
// Reproduces the @clack/prompts password frame (pinned to the installed version)
// so this prompt is visually identical to every other setup prompt; the only
// behavioral change is the tail-revealing `masked` getter above.
function renderPasswordFrame(prompt: Omit<PasswordPrompt, 'prompt'>, message: string): string {
const withGuide = settings.withGuide;
const title = `${withGuide ? `${styleText('gray', S_BAR)}\n` : ''}${symbol(prompt.state)} ${message}\n`;
const masked = prompt.masked;
switch (prompt.state) {
case 'error': {
const bar = withGuide ? `${styleText('yellow', S_BAR)} ` : '';
const end = withGuide ? `${styleText('yellow', S_BAR_END)} ` : '';
return `${title.trim()}\n${bar}${masked}\n${end}${styleText('yellow', prompt.error)}\n`;
}
case 'submit': {
const bar = withGuide ? `${styleText('gray', S_BAR)} ` : '';
return `${title}${bar}${masked ? styleText('dim', masked) : ''}`;
}
case 'cancel': {
const bar = withGuide ? `${styleText('gray', S_BAR)} ` : '';
const body = masked ? styleText(['strikethrough', 'dim'], masked) : '';
return `${title}${bar}${body}${masked && withGuide ? `\n${styleText('gray', S_BAR)}` : ''}`;
}
default: {
const bar = withGuide ? `${styleText('cyan', S_BAR)} ` : '';
const end = withGuide ? styleText('cyan', S_BAR_END) : '';
return `${title}${bar}${prompt.userInputWithCursor}\n${end}\n`;
}
}
}
export interface RevealPasswordOptions {
message: string;
mask?: string;
tail?: number;
validate?: PasswordOptions['validate'];
signal?: AbortSignal;
}
/**
* Drop-in replacement for clack's `password()` that reveals the last few
* characters of the entered value while typing. Resolves to the raw value or the
* clack cancel symbol, matching `password()`'s contract.
*/
export function revealPassword(options: RevealPasswordOptions): Promise<string | symbol> {
const prompt = new RevealTailPasswordPrompt({
mask: options.mask ?? S_PASSWORD_MASK,
tail: options.tail ?? REVEAL_TAIL_COUNT,
validate: options.validate,
signal: options.signal,
render() {
return renderPasswordFrame(this, options.message);
},
});
return prompt.prompt() as Promise<string | symbol>;
}

View file

@ -73,6 +73,7 @@ export type KtxSetupDatabaseDriver =
export interface KtxSetupDatabasesArgs {
projectDir: string;
inputMode: 'auto' | 'disabled';
debug?: boolean;
yes?: boolean;
cliVersion?: string;
runtimeInstallPolicy?: KtxManagedPythonInstallPolicy;
@ -1626,7 +1627,12 @@ function hasServiceAccountsBlock(connection: KtxProjectConnectionConfig | undefi
return 'serviceAccounts' in filters;
}
function printQueryHistoryFilterProposal(io: KtxCliIo, proposal: QueryHistoryFilterProposal): void {
function printQueryHistoryFilterProposal(io: KtxCliIo, proposal: QueryHistoryFilterProposal, debug = false): void {
if (debug && proposal.parseFailedTemplateIds.length > 0) {
io.stderr.write(
`[debug] query-history filter picker could not parse ${proposal.parseFailedTemplateIds.length} template(s): ${proposal.parseFailedTemplateIds.join(', ')}\n`,
);
}
if (proposal.excludedRoles.length === 0) {
if (proposal.skipped?.reason === 'no-llm') {
io.stdout.write('│ Query-history filter picker skipped: no LLM is configured.\n');
@ -1635,6 +1641,12 @@ function printQueryHistoryFilterProposal(io: KtxCliIo, proposal: QueryHistoryFil
} else if (proposal.skipped?.reason === 'no-in-scope-history') {
io.stdout.write('│ Query-history filter picker found no in-scope service-account exclusions.\n');
}
if (proposal.parseFailedTemplateIds.length > 0) {
const count = proposal.parseFailedTemplateIds.length;
io.stdout.write(
`│ Skipped ${count} query template${count === 1 ? '' : 's'} ktx could not parse (run with --debug to list them).\n`,
);
}
for (const warning of proposal.warnings) {
io.stdout.write(`│ ! ${warning}\n`);
}
@ -1727,12 +1739,17 @@ async function maybeProposeQueryHistoryFilters(input: {
deps: input.deps,
});
if (!llmRuntime && !input.deps.queryHistoryFilterPicker) {
printQueryHistoryFilterProposal(input.io, {
excludedRoles: [],
consideredRoleCount: 0,
skipped: { reason: 'no-llm' },
warnings: [],
});
printQueryHistoryFilterProposal(
input.io,
{
excludedRoles: [],
consideredRoleCount: 0,
skipped: { reason: 'no-llm' },
warnings: [],
parseFailedTemplateIds: [],
},
input.args.debug === true,
);
return;
}
@ -1773,7 +1790,19 @@ async function maybeProposeQueryHistoryFilters(input: {
userServiceAccountsPresent,
});
printQueryHistoryFilterProposal(input.io, proposal);
printQueryHistoryFilterProposal(input.io, proposal, input.args.debug === true);
await emitTelemetryEvent({
name: 'query_history_filter_completed',
projectDir: input.projectDir,
io: input.io,
fields: {
dialect,
consideredRoleCount: proposal.consideredRoleCount,
excludedRoleCount: proposal.excludedRoles.length,
parseFailedCount: proposal.parseFailedTemplateIds.length,
outcome: 'ok',
},
});
if (proposal.skipped?.reason === 'user-block-present') {
input.io.stdout.write('│ Existing query-history service-account filters left unchanged.\n');
return;

View file

@ -222,8 +222,8 @@ async function chooseCredentialRef(
const choice = await prompts.select({
message: `How should KTX find your ${embeddingBackendDisplayName(backend)} embedding API key?`,
options: [
{ value: 'env', label: `Use ${defaultEnv} from the environment` },
{ value: 'paste', label: 'Paste a key and save it as a local secret file' },
{ value: 'env', label: `Use ${defaultEnv} from the environment` },
{ value: 'back', label: 'Back' },
],
});

View file

@ -470,8 +470,8 @@ async function chooseCredentialRef(
const choice = await prompts.select({
message: `How should KTX find your Anthropic API key?\n\n${ANTHROPIC_CREDENTIAL_PROMPT_CONTEXT}`,
options: [
{ value: 'env', label: 'Use ANTHROPIC_API_KEY from the environment' },
{ value: 'paste', label: 'Paste a key and save it as a local secret file' },
{ value: 'env', label: 'Use ANTHROPIC_API_KEY from the environment' },
{ value: 'back', label: 'Back' },
],
});

View file

@ -9,12 +9,12 @@ import {
log,
multiselect,
note,
password,
select,
text,
} from '@clack/prompts';
import type { KtxCliIo } from './cli-runtime.js';
import { withMenuOptionsSpacing, withTextInputNavigation } from './prompt-navigation.js';
import { revealPassword } from './reveal-password-prompt.js';
import { withSetupInterruptConfirmation } from './setup-interrupt.js';
export interface KtxSetupPromptOption<Value extends string = string> {
@ -189,7 +189,7 @@ export function createKtxSetupPromptAdapter(options: KtxSetupPromptAdapterOption
},
async password(promptOptions) {
const value = await withSetupInterruptConfirmation(() =>
password({ ...promptOptions, message: withTextInputNavigation(promptOptions.message) }),
revealPassword({ ...promptOptions, message: withTextInputNavigation(promptOptions.message) }),
);
return isCancel(value) ? undefined : String(value);
},

View file

@ -119,11 +119,11 @@ export interface KtxSetupSourcesDeps {
const SOURCE_OPTIONS: Array<{ value: KtxSetupSourceType; label: string }> = [
{ value: 'dbt', label: 'dbt' },
{ value: 'metricflow', label: 'MetricFlow' },
{ value: 'metabase', label: 'Metabase' },
{ value: 'notion', label: 'Notion' },
{ value: 'metricflow', label: 'MetricFlow' },
{ value: 'looker', label: 'Looker' },
{ value: 'lookml', label: 'LookML' },
{ value: 'notion', label: 'Notion' },
];
const SOURCE_LABELS = Object.fromEntries(SOURCE_OPTIONS.map((option) => [option.value, option.label])) as Record<
@ -269,8 +269,8 @@ async function chooseSourceCredentialRef(input: {
message: `How should KTX find your ${input.label}?`,
options: [
...(input.existingRef ? [{ value: 'keep', label: 'Keep existing credential' }] : []),
{ value: 'env', label: `Use ${input.envName} from the environment` },
{ value: 'paste', label: 'Paste a key and save it as a local secret file' },
{ value: 'env', label: `Use ${input.envName} from the environment` },
{ value: 'back', label: 'Back' },
],
});
@ -307,8 +307,8 @@ async function chooseGitAuthCredentialRef(input: {
message: `${label} repo requires authentication.`,
options: [
...(input.existingRef ? [{ value: 'keep', label: 'Keep existing credential' }] : []),
{ value: 'env', label: 'Use GITHUB_TOKEN from the environment' },
{ value: 'paste', label: 'Paste a token and save it as a local secret file' },
{ value: 'env', label: 'Use GITHUB_TOKEN from the environment' },
{ value: 'skip', label: 'Skip — try without authentication' },
{ value: 'back', label: 'Back' },
],
@ -1063,8 +1063,8 @@ async function promptForInteractiveSource(
const selectedLocation = await prompts.select({
message: `${source} source location`,
options: [
{ value: 'path', label: 'Local path' },
{ value: 'git', label: 'Git URL' },
{ value: 'path', label: 'Local path' },
{ value: 'back', label: 'Back' },
],
});
@ -1343,8 +1343,8 @@ async function promptForInteractiveSource(
const crawlMode = await prompts.select({
message: 'Which Notion pages should KTX ingest?',
options: [
{ value: 'selected_roots', label: 'Specific pages and their subpages (choose them in a picker)' },
{ value: 'all_accessible', label: 'All pages the integration can access' },
{ value: 'selected_roots', label: 'Specific pages and their subpages (choose them in a picker)' },
{ value: 'back', label: 'Back' },
],
});
@ -2064,7 +2064,7 @@ export async function runKtxSetupSourcesStep(
const addMore = await prompts.select({
message: `${readyConnectionIds.length} context source${readyConnectionIds.length > 1 ? 's' : ''} configured (${readyConnectionIds.join(', ')}). Add another?`,
options: [
{ value: 'done', label: 'Done — continue to context build' },
{ value: 'done', label: 'Done adding context sources' },
{ value: 'edit', label: 'Edit an existing context source' },
{ value: 'add', label: 'Add another context source' },
],

View file

@ -80,6 +80,7 @@ export type KtxSetupArgs =
agentScope?: KtxAgentScope;
skipAgents?: boolean;
inputMode: 'auto' | 'disabled';
debug?: boolean;
yes: boolean;
cliVersion: string;
llmBackend?: KtxSetupLlmBackend;
@ -735,6 +736,7 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup
{
projectDir: projectResult.projectDir,
inputMode: args.inputMode,
...(args.debug !== undefined ? { debug: args.debug } : {}),
yes: args.yes,
cliVersion: args.cliVersion,
runtimeInstallPolicy: setupRuntimeInstallPolicy(args),

View file

@ -206,6 +206,17 @@
"errorClass",
"durationMs"
]
},
{
"name": "query_history_filter_completed",
"description": "Emitted after the setup query-history service-account filter picker runs.",
"fields": [
"dialect",
"consideredRoleCount",
"excludedRoleCount",
"parseFailedCount",
"outcome"
]
}
],
"$defs": {
@ -1434,6 +1445,77 @@
"durationMs"
],
"additionalProperties": false
},
"query_history_filter_completed": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"cliVersion": {
"type": "string"
},
"nodeVersion": {
"type": "string"
},
"osPlatform": {
"type": "string"
},
"osRelease": {
"type": "string"
},
"arch": {
"type": "string"
},
"runtime": {
"type": "string",
"enum": [
"node",
"daemon-py"
]
},
"isCi": {
"type": "boolean"
},
"dialect": {
"type": "string"
},
"consideredRoleCount": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"excludedRoleCount": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"parseFailedCount": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"outcome": {
"type": "string",
"enum": [
"ok",
"error"
]
}
},
"required": [
"cliVersion",
"nodeVersion",
"osPlatform",
"osRelease",
"arch",
"runtime",
"isCi",
"dialect",
"consideredRoleCount",
"excludedRoleCount",
"parseFailedCount",
"outcome"
],
"additionalProperties": false
}
}
}

View file

@ -206,6 +206,16 @@ const sqlGenCompletedSchema = telemetryCommonEnvelopeSchema
})
.strict();
const queryHistoryFilterCompletedSchema = telemetryCommonEnvelopeSchema
.extend({
dialect: z.string(),
consideredRoleCount: z.number().int().nonnegative(),
excludedRoleCount: z.number().int().nonnegative(),
parseFailedCount: z.number().int().nonnegative(),
outcome: outcomeSchema,
})
.strict();
/** @internal */
export const telemetryEventSchemas = {
install_first_run: installFirstRunSchema,
@ -225,6 +235,7 @@ export const telemetryEventSchemas = {
daemon_stopped: daemonStoppedSchema,
sl_plan_completed: slPlanCompletedSchema,
sql_gen_completed: sqlGenCompletedSchema,
query_history_filter_completed: queryHistoryFilterCompletedSchema,
} as const;
/** @internal */
@ -360,6 +371,11 @@ export const telemetryEventCatalog = [
description: 'Emitted after daemon SQL generation completes.',
fields: ['outcome', 'dialect', 'errorClass', 'durationMs'],
},
{
name: 'query_history_filter_completed',
description: 'Emitted after the setup query-history service-account filter picker runs.',
fields: ['dialect', 'consideredRoleCount', 'excludedRoleCount', 'parseFailedCount', 'outcome'],
},
] as const;
export type TelemetryEventName = keyof typeof telemetryEventSchemas;