mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-07 07:55:13 +02:00
* feat(query-history): structure SQL analysis table refs * feat(query-history): qualify SQL analysis table refs * feat(query-history): wire modeled scope floor through ingest * chore(query-history): verify scope floor * test(query-history): align daemon SQL batch endpoint contract * feat(query-history): build scope from same-run scan catalog * feat(query-history): fail open on scope-floor catalog failures * chore(query-history): verify scope-floor v1 closure * refactor(query-history): share scope membership * feat(setup): apply derived query history filters * docs: document derived query history filters * fix(query-history): redact filter picker LLM prompt SQL * fix(setup): run filter picker SQL analysis through managed daemon * chore(query-history): verify filter picker v1 closure * fix(query-history): fail open on partial service-account attribution * fix(query-history): aggregate BigQuery users by execution count * fix(query-history): aggregate Snowflake users by execution count * fix(query-history): use BigQuery query info hash
925 lines
35 KiB
TypeScript
925 lines
35 KiB
TypeScript
import { existsSync } from 'node:fs';
|
|
import { basename, join, resolve } from 'node:path';
|
|
import { getLatestLocalIngestStatus } from './context/ingest/local-ingest.js';
|
|
import { ingestReportOutcome, savedMemoryCountsForReport } from './context/ingest/reports.js';
|
|
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 { formatNextStepLines, formatSetupNextStepLines } from './next-steps.js';
|
|
import { runtimeInstallPolicyFromFlags } from './managed-python-command.js';
|
|
import { readManagedPythonRuntimeStatus } from './managed-python-runtime.js';
|
|
import { resolveProjectRuntimeRequirements } from './runtime-requirements.js';
|
|
import { isKtxSetupExitError } from './setup-interrupt.js';
|
|
import {
|
|
type KtxAgentScope,
|
|
type KtxAgentTarget,
|
|
type KtxSetupAgentsDeps,
|
|
readKtxAgentInstallManifest,
|
|
runKtxSetupAgentsStep,
|
|
targetDisplayName,
|
|
} from './setup-agents.js';
|
|
import {
|
|
type KtxSetupDatabaseDriver,
|
|
type KtxSetupDatabasesDeps,
|
|
runKtxSetupDatabasesStep,
|
|
} from './setup-databases.js';
|
|
import { type KtxSetupEmbeddingsDeps, runKtxSetupEmbeddingsStep } from './setup-embeddings.js';
|
|
import {
|
|
type KtxSetupLlmBackend,
|
|
type KtxSetupModelDeps,
|
|
isKtxSetupLlmConfigReady,
|
|
runKtxSetupAnthropicModelStep,
|
|
} from './setup-models.js';
|
|
import { type KtxSetupProjectDeps, runKtxSetupProjectStep } from './setup-project.js';
|
|
import {
|
|
classifyKtxSetupCompletion,
|
|
type KtxSetupReadyMenuDeps,
|
|
runKtxSetupReadyMenu,
|
|
setupHasContextTargets,
|
|
} from './setup-ready-menu.js';
|
|
import { type KtxSetupSourcesDeps, type KtxSetupSourceType, runKtxSetupSourcesStep } from './setup-sources.js';
|
|
import {
|
|
type KtxSetupRuntimeDeps,
|
|
type KtxSetupRuntimeResult,
|
|
runKtxSetupRuntimeStep,
|
|
} from './setup-runtime.js';
|
|
import {
|
|
createKtxSetupPromptAdapter,
|
|
createKtxSetupUiAdapter,
|
|
type KtxSetupPromptOption,
|
|
type KtxSetupUiAdapter,
|
|
} from './setup-prompts.js';
|
|
import {
|
|
readKtxSetupContextState,
|
|
type KtxSetupContextDeps,
|
|
type KtxSetupContextResult,
|
|
runKtxSetupContextStep,
|
|
setupContextStatusFromState,
|
|
type KtxSetupContextStatusSummary,
|
|
} from './setup-context.js';
|
|
|
|
export interface KtxSetupStatus {
|
|
project: { path: string; ready: boolean; name?: string };
|
|
llm: { backend?: string; ready: boolean; model?: string };
|
|
embeddings: { backend?: string; ready: boolean; model?: string; dimensions?: number };
|
|
databases: Array<{ connectionId: string; ready: boolean }>;
|
|
sources: Array<{ connectionId: string; type: string; ready: boolean }>;
|
|
runtime: { required: boolean; ready: boolean; features: string[]; detail?: string };
|
|
context: KtxSetupContextStatusSummary;
|
|
agents: Array<{ target: string; scope: string; ready: boolean }>;
|
|
}
|
|
|
|
export type KtxSetupArgs =
|
|
| {
|
|
command: 'run';
|
|
projectDir: string;
|
|
mode: 'auto';
|
|
agents: boolean;
|
|
target?: KtxAgentTarget;
|
|
agentScope?: KtxAgentScope;
|
|
skipAgents?: boolean;
|
|
inputMode: 'auto' | 'disabled';
|
|
yes: boolean;
|
|
cliVersion: string;
|
|
llmBackend?: KtxSetupLlmBackend;
|
|
anthropicApiKeyEnv?: string;
|
|
anthropicApiKeyFile?: string;
|
|
llmModel?: string;
|
|
vertexProject?: string;
|
|
vertexLocation?: string;
|
|
skipLlm: boolean;
|
|
embeddingBackend?: 'openai' | 'sentence-transformers';
|
|
embeddingApiKeyEnv?: string;
|
|
embeddingApiKeyFile?: string;
|
|
skipEmbeddings: boolean;
|
|
databaseDrivers?: KtxSetupDatabaseDriver[];
|
|
databaseConnectionIds?: string[];
|
|
databaseConnectionId?: string;
|
|
databaseUrl?: string;
|
|
databaseSchemas: string[];
|
|
enableQueryHistory?: boolean;
|
|
disableQueryHistory?: boolean;
|
|
queryHistoryWindowDays?: number;
|
|
queryHistoryMinExecutions?: number;
|
|
queryHistoryServiceAccountPatterns?: string[];
|
|
queryHistoryRedactionPatterns?: string[];
|
|
skipDatabases: boolean;
|
|
source?: KtxSetupSourceType;
|
|
sourceConnectionId?: string;
|
|
sourcePath?: string;
|
|
sourceGitUrl?: string;
|
|
sourceBranch?: string;
|
|
sourceSubpath?: string;
|
|
sourceAuthTokenRef?: string;
|
|
sourceUrl?: string;
|
|
sourceApiKeyRef?: string;
|
|
sourceClientId?: string;
|
|
sourceClientSecretRef?: string;
|
|
sourceWarehouseConnectionId?: string;
|
|
sourceProjectName?: string;
|
|
sourceProfilesPath?: string;
|
|
sourceTarget?: string;
|
|
metabaseDatabaseId?: number;
|
|
notionCrawlMode?: 'all_accessible' | 'selected_roots';
|
|
notionRootPageIds?: string[];
|
|
runInitialSourceIngest?: boolean;
|
|
skipSources?: boolean;
|
|
showEntryMenu?: boolean;
|
|
};
|
|
|
|
export interface KtxSetupDeps {
|
|
project?: KtxSetupProjectDeps;
|
|
model?: (
|
|
args: Parameters<typeof runKtxSetupAnthropicModelStep>[0],
|
|
io: KtxCliIo,
|
|
) => Promise<Awaited<ReturnType<typeof runKtxSetupAnthropicModelStep>>>;
|
|
modelDeps?: KtxSetupModelDeps;
|
|
embeddings?: (
|
|
args: Parameters<typeof runKtxSetupEmbeddingsStep>[0],
|
|
io: KtxCliIo,
|
|
) => Promise<Awaited<ReturnType<typeof runKtxSetupEmbeddingsStep>>>;
|
|
embeddingsDeps?: KtxSetupEmbeddingsDeps;
|
|
databases?: (
|
|
args: Parameters<typeof runKtxSetupDatabasesStep>[0],
|
|
io: KtxCliIo,
|
|
) => Promise<Awaited<ReturnType<typeof runKtxSetupDatabasesStep>>>;
|
|
databasesDeps?: KtxSetupDatabasesDeps;
|
|
sources?: (
|
|
args: Parameters<typeof runKtxSetupSourcesStep>[0],
|
|
io: KtxCliIo,
|
|
) => Promise<Awaited<ReturnType<typeof runKtxSetupSourcesStep>>>;
|
|
sourcesDeps?: KtxSetupSourcesDeps;
|
|
runtime?: (args: Parameters<typeof runKtxSetupRuntimeStep>[0], io: KtxCliIo) => Promise<KtxSetupRuntimeResult>;
|
|
runtimeDeps?: KtxSetupRuntimeDeps;
|
|
agents?: (
|
|
args: Parameters<typeof runKtxSetupAgentsStep>[0],
|
|
io: KtxCliIo,
|
|
) => Promise<Awaited<ReturnType<typeof runKtxSetupAgentsStep>>>;
|
|
agentsDeps?: KtxSetupAgentsDeps;
|
|
context?: (args: Parameters<typeof runKtxSetupContextStep>[0], io: KtxCliIo) => Promise<KtxSetupContextResult>;
|
|
contextDeps?: KtxSetupContextDeps;
|
|
readyMenuDeps?: KtxSetupReadyMenuDeps;
|
|
entryMenuDeps?: KtxSetupEntryMenuDeps;
|
|
setupUi?: KtxSetupUiAdapter;
|
|
}
|
|
|
|
const SOURCE_DRIVERS = new Set(['dbt', 'metricflow', 'metabase', 'looker', 'lookml', 'notion']);
|
|
const KTX_DOCS_URL = 'https://docs.kaelio.com/ktx';
|
|
|
|
type KtxSetupEntryAction = 'setup' | 'new-project' | 'agents' | 'status' | 'demo' | 'exit';
|
|
type KtxSetupFlowStep = 'models' | 'embeddings' | 'databases' | 'sources' | 'runtime' | 'context' | 'agents';
|
|
type KtxSetupFlowStatus =
|
|
| 'ready'
|
|
| 'skipped'
|
|
| 'back'
|
|
| 'missing-input'
|
|
| 'failed';
|
|
type TelemetrySetupStep =
|
|
| 'project'
|
|
| 'runtime'
|
|
| 'models'
|
|
| 'embeddings'
|
|
| 'databases'
|
|
| 'sources'
|
|
| 'context'
|
|
| 'agents'
|
|
| 'demo-tour';
|
|
|
|
export interface KtxSetupEntryMenuPromptAdapter {
|
|
select(options: { message: string; options: KtxSetupPromptOption[] }): Promise<string>;
|
|
cancel(message: string): void;
|
|
}
|
|
|
|
export interface KtxSetupEntryMenuDeps {
|
|
prompts?: KtxSetupEntryMenuPromptAdapter;
|
|
}
|
|
|
|
function createEntryMenuPromptAdapter(): KtxSetupEntryMenuPromptAdapter {
|
|
return createKtxSetupPromptAdapter({
|
|
selectCancelValue: 'exit',
|
|
cancelOnSelectCancel: false,
|
|
});
|
|
}
|
|
|
|
function setupTelemetryOutcome(
|
|
status: KtxSetupFlowStatus | Extract<Awaited<ReturnType<typeof runKtxSetupProjectStep>>, { status: string }>['status'],
|
|
): 'completed' | 'skipped' | 'abandoned' {
|
|
if (status === 'ready') return 'completed';
|
|
if (status === 'skipped') return 'skipped';
|
|
return 'abandoned';
|
|
}
|
|
|
|
async function recordSetupStep(input: {
|
|
projectDir: string;
|
|
step: TelemetrySetupStep;
|
|
status: KtxSetupFlowStatus | Extract<Awaited<ReturnType<typeof runKtxSetupProjectStep>>, { status: string }>['status'];
|
|
startedAt: number;
|
|
io: KtxCliIo;
|
|
cliVersion?: string;
|
|
errorDetail?: string;
|
|
}): Promise<void> {
|
|
const { emitTelemetryEvent } = await import('./telemetry/index.js');
|
|
await emitTelemetryEvent({
|
|
name: 'setup_step',
|
|
projectDir: input.projectDir,
|
|
io: input.io,
|
|
packageInfo: { ...getKtxCliPackageInfo(), version: input.cliVersion ?? getKtxCliPackageInfo().version },
|
|
fields: {
|
|
step: input.step,
|
|
outcome: setupTelemetryOutcome(input.status),
|
|
durationMs: Math.max(0, performance.now() - input.startedAt),
|
|
...(input.errorDetail ? { errorDetail: input.errorDetail } : {}),
|
|
},
|
|
});
|
|
}
|
|
|
|
async function runKtxSetupEntryMenu(
|
|
status: KtxSetupStatus,
|
|
deps: KtxSetupEntryMenuDeps = {},
|
|
): Promise<{ action: KtxSetupEntryAction }> {
|
|
const prompts = deps.prompts ?? createEntryMenuPromptAdapter();
|
|
const options = status.project.ready
|
|
? [
|
|
{ value: 'setup', label: 'Resume or change an existing setup' },
|
|
{ value: 'new-project', label: 'Create a new KTX project' },
|
|
{ value: 'agents', label: 'Connect a coding agent to KTX' },
|
|
{ value: 'status', label: 'Check setup status' },
|
|
{ value: 'demo', label: 'Explore a pre-built KTX project' },
|
|
{ value: 'exit', label: 'Exit' },
|
|
]
|
|
: [
|
|
{ value: 'setup', label: 'Set up KTX for my data' },
|
|
{ value: 'status', label: 'Check setup status' },
|
|
{ value: 'demo', label: 'Explore a pre-built KTX project' },
|
|
{ value: 'exit', label: 'Exit' },
|
|
];
|
|
const action = (await prompts.select({
|
|
message: 'What do you want to do?',
|
|
options,
|
|
})) as KtxSetupEntryAction;
|
|
return { action };
|
|
}
|
|
|
|
async function runKtxSetupDemoFromEntryMenu(
|
|
args: Extract<KtxSetupArgs, { command: 'run' }>,
|
|
io: KtxCliIo,
|
|
deps: KtxSetupDeps,
|
|
): Promise<number> {
|
|
const { runDemoTour } = await import('./setup-demo-tour.js');
|
|
const startedAt = performance.now();
|
|
const result = await runDemoTour(
|
|
{ inputMode: args.inputMode, cliVersion: args.cliVersion },
|
|
io,
|
|
{ agents: deps.agents },
|
|
);
|
|
await recordSetupStep({
|
|
projectDir: args.projectDir,
|
|
step: 'demo-tour',
|
|
status: result === 0 ? 'ready' : 'failed',
|
|
startedAt,
|
|
io,
|
|
cliVersion: args.cliVersion,
|
|
});
|
|
return result;
|
|
}
|
|
|
|
function embeddingsReady(status: KtxSetupStatus['embeddings']): boolean {
|
|
return (
|
|
status.backend !== undefined &&
|
|
status.backend !== 'none' &&
|
|
typeof status.model === 'string' &&
|
|
status.model.length > 0 &&
|
|
typeof status.dimensions === 'number' &&
|
|
status.dimensions > 0
|
|
);
|
|
}
|
|
|
|
function sourceConnections(config: Awaited<ReturnType<typeof loadKtxProject>>['config']) {
|
|
return Object.entries(config.connections)
|
|
.filter(([, connection]) => SOURCE_DRIVERS.has(String(connection.driver ?? '').toLowerCase()))
|
|
.map(([connectionId, connection]) => ({
|
|
connectionId,
|
|
type: String(connection.driver).toLowerCase(),
|
|
}))
|
|
.sort((left, right) => left.connectionId.localeCompare(right.connectionId));
|
|
}
|
|
|
|
type LocalIngestStatusReport = NonNullable<Awaited<ReturnType<typeof getLatestLocalIngestStatus>>>;
|
|
|
|
function reportHasSavedContext(report: LocalIngestStatusReport): boolean {
|
|
if (ingestReportOutcome(report) === 'error') {
|
|
return false;
|
|
}
|
|
const counts = savedMemoryCountsForReport(report);
|
|
return counts.wikiCount > 0 || counts.slCount > 0;
|
|
}
|
|
|
|
async function readIngestContextStatus(project: KtxLocalProject): Promise<KtxSetupContextStatusSummary | null> {
|
|
if (!existsSync(ktxLocalStateDbPath(project))) {
|
|
return null;
|
|
}
|
|
const report = await getLatestLocalIngestStatus(project);
|
|
if (!report || !reportHasSavedContext(report)) {
|
|
return null;
|
|
}
|
|
return {
|
|
ready: true,
|
|
status: 'completed',
|
|
runId: report.runId,
|
|
};
|
|
}
|
|
|
|
export interface ReadKtxSetupStatusOptions {
|
|
cliVersion?: string;
|
|
env?: NodeJS.ProcessEnv;
|
|
readRuntimeStatus?: typeof readManagedPythonRuntimeStatus;
|
|
}
|
|
|
|
export async function readKtxSetupStatus(
|
|
projectDir: string,
|
|
options: ReadKtxSetupStatusOptions = {},
|
|
): Promise<KtxSetupStatus> {
|
|
const resolvedProjectDir = resolve(projectDir);
|
|
if (!existsSync(join(resolvedProjectDir, 'ktx.yaml'))) {
|
|
return {
|
|
project: { path: resolvedProjectDir, ready: false },
|
|
llm: { ready: false },
|
|
embeddings: { ready: false },
|
|
databases: [],
|
|
sources: [],
|
|
runtime: { required: false, ready: true, features: [] },
|
|
context: setupContextStatusFromState(await readKtxSetupContextState(resolvedProjectDir)),
|
|
agents: [],
|
|
};
|
|
}
|
|
|
|
const project = await loadKtxProject({ projectDir: resolvedProjectDir });
|
|
const llm = {
|
|
backend: project.config.llm.provider.backend,
|
|
ready: isKtxSetupLlmConfigReady(project.config.llm),
|
|
model: project.config.llm.models.default,
|
|
};
|
|
|
|
const embeddings = {
|
|
backend: project.config.ingest.embeddings.backend,
|
|
ready: false,
|
|
model: project.config.ingest.embeddings.model,
|
|
dimensions: project.config.ingest.embeddings.dimensions,
|
|
};
|
|
embeddings.ready = embeddingsReady(embeddings);
|
|
|
|
const completedSteps = (await readKtxSetupState(resolvedProjectDir)).completed_steps;
|
|
const contextState = await readKtxSetupContextState(resolvedProjectDir);
|
|
const setupContextStatus = setupContextStatusFromState(contextState, {
|
|
completedStep: completedSteps.includes('context'),
|
|
});
|
|
const ingestContextStatus = setupContextStatus.ready ? null : await readIngestContextStatus(project);
|
|
const databaseIds = project.config.setup?.database_connection_ids ?? Object.keys(project.config.connections);
|
|
const databasesComplete = completedSteps.includes('databases');
|
|
const manifest = await readKtxAgentInstallManifest(resolvedProjectDir);
|
|
const agentMap = new Map<string, { target: string; scope: string; ready: boolean }>();
|
|
for (const install of manifest?.installs ?? []) {
|
|
agentMap.set(`${install.target}:${install.scope}`, {
|
|
target: install.target,
|
|
scope: install.scope,
|
|
ready: true,
|
|
});
|
|
}
|
|
const agents = [...agentMap.values()];
|
|
const runtimeRequirements = resolveProjectRuntimeRequirements(project.config, {
|
|
env: options.env ?? process.env,
|
|
});
|
|
let runtimeReady = runtimeRequirements.features.length === 0 || completedSteps.includes('runtime');
|
|
let runtimeDetail: string | undefined;
|
|
if (runtimeRequirements.features.length > 0 && options.cliVersion) {
|
|
const readRuntimeStatus = options.readRuntimeStatus ?? readManagedPythonRuntimeStatus;
|
|
const runtimeStatus = await readRuntimeStatus({ cliVersion: options.cliVersion, env: options.env ?? process.env });
|
|
runtimeDetail = runtimeStatus.detail;
|
|
runtimeReady =
|
|
runtimeStatus.kind === 'ready' &&
|
|
runtimeStatus.manifest !== undefined &&
|
|
runtimeRequirements.features.every((feature) => runtimeStatus.manifest?.features.includes(feature));
|
|
}
|
|
|
|
return {
|
|
project: { path: resolvedProjectDir, ready: true, name: basename(project.projectDir) || project.projectDir },
|
|
llm,
|
|
embeddings,
|
|
databases: databaseIds.map((connectionId) => ({
|
|
connectionId,
|
|
ready: databasesComplete && Object.hasOwn(project.config.connections, connectionId),
|
|
})),
|
|
sources: sourceConnections(project.config).map((source) => ({
|
|
...source,
|
|
ready: completedSteps.includes('sources'),
|
|
})),
|
|
runtime: {
|
|
required: runtimeRequirements.features.length > 0,
|
|
ready: runtimeReady,
|
|
features: runtimeRequirements.features,
|
|
...(runtimeDetail ? { detail: runtimeDetail } : {}),
|
|
},
|
|
context: ingestContextStatus ?? setupContextStatus,
|
|
agents,
|
|
};
|
|
}
|
|
|
|
function formatReady(value: boolean): 'yes' | 'no' {
|
|
return value ? 'yes' : 'no';
|
|
}
|
|
|
|
function formatConnectionList(ids: string[]): string {
|
|
return ids.length > 0 ? `yes (${ids.join(', ')})` : 'no';
|
|
}
|
|
|
|
function formatContextBuilt(status: KtxSetupContextStatusSummary): string {
|
|
if (status.ready) {
|
|
return 'yes';
|
|
}
|
|
if (status.status === 'not_started') {
|
|
return 'no';
|
|
}
|
|
const runSuffix = status.runId ? ` (${status.runId})` : '';
|
|
return `${status.status.replaceAll('_', ' ')}${runSuffix}`;
|
|
}
|
|
|
|
export function formatKtxSetupStatus(status: KtxSetupStatus): string {
|
|
if (!status.project.ready) {
|
|
return [
|
|
`No KTX project found at ${status.project.path}.`,
|
|
'',
|
|
'Check another project: ktx --project-dir <folder> status',
|
|
'Or from that folder: ktx status',
|
|
'Create a new KTX project here: ktx setup',
|
|
'',
|
|
].join('\n');
|
|
}
|
|
|
|
const lines = [
|
|
`KTX project: ${status.project.path}`,
|
|
`Project ready: ${formatReady(status.project.ready)}`,
|
|
`LLM ready: ${formatReady(status.llm.ready)}${status.llm.model ? ` (${status.llm.model})` : ''}`,
|
|
`Embeddings ready: ${formatReady(status.embeddings.ready)}${
|
|
status.embeddings.model ? ` (${status.embeddings.model})` : ''
|
|
}`,
|
|
`Databases configured: ${formatConnectionList(status.databases.map((database) => database.connectionId))}`,
|
|
`Context sources configured: ${formatConnectionList(status.sources.map((source) => source.connectionId))}`,
|
|
...(status.runtime.required
|
|
? [
|
|
`Runtime ready: ${formatReady(status.runtime.ready)}${
|
|
status.runtime.features.length > 0 ? ` (${status.runtime.features.join(', ')})` : ''
|
|
}`,
|
|
]
|
|
: []),
|
|
`KTX context built: ${formatContextBuilt(status.context)}`,
|
|
`Agent integration ready: ${formatReady(status.agents.some((agent) => agent.ready))}${
|
|
status.agents.length > 0 ? ` (${status.agents.map((agent) => `${agent.target}:${agent.scope}`).join(', ')})` : ''
|
|
}`,
|
|
];
|
|
if (!status.context.ready && status.context.status === 'failed' && status.context.detail) {
|
|
lines.push(`Retry: ${status.context.retryCommand ?? `ktx setup --project-dir ${status.project.path}`}`);
|
|
}
|
|
|
|
return `${lines.join('\n')}\n`;
|
|
}
|
|
|
|
export function formatKtxSetupCompletionSummary(
|
|
status: KtxSetupStatus,
|
|
options: { agentNextActions?: string } = {},
|
|
): string {
|
|
const readyAgents = status.agents.filter((agent) => agent.ready).map((agent) => targetDisplayName(agent.target));
|
|
const lines = [
|
|
'Project',
|
|
` ${status.project.path}`,
|
|
'',
|
|
'Context',
|
|
` ${status.context.ready ? 'built' : formatContextBuilt(status.context)}`,
|
|
'',
|
|
'Agents configured',
|
|
` ${readyAgents.length > 0 ? readyAgents.join(', ') : 'not installed'}`,
|
|
];
|
|
const agentNextActions = options.agentNextActions?.trim();
|
|
if (agentNextActions) {
|
|
lines.push(
|
|
'',
|
|
'REQUIRED BEFORE USING AGENTS',
|
|
'',
|
|
...agentNextActions.split('\n').map((line) => (line ? ` ${line}` : '')),
|
|
);
|
|
}
|
|
lines.push('', agentNextActions ? 'After that, try' : 'Try it');
|
|
lines.push(' Ask your agent: "Use KTX to show me the available tables."');
|
|
return lines.join('\n');
|
|
}
|
|
|
|
function setupStatusReady(status: KtxSetupStatus): boolean {
|
|
if (!status.project.ready) {
|
|
return false;
|
|
}
|
|
if (!setupHasContextTargets(status)) {
|
|
return true;
|
|
}
|
|
return (
|
|
status.llm.ready &&
|
|
embeddingsReady(status.embeddings) &&
|
|
status.databases.every((database) => database.ready) &&
|
|
status.sources.every((source) => source.ready) &&
|
|
status.runtime.ready
|
|
);
|
|
}
|
|
|
|
function setupContextReady(status: KtxSetupStatus): boolean {
|
|
return status.context.ready;
|
|
}
|
|
|
|
function shouldPrintConciseReadySummary(status: KtxSetupStatus): boolean {
|
|
return setupStatusReady(status) && setupContextReady(status) && status.agents.some((agent) => agent.ready);
|
|
}
|
|
|
|
function setupRuntimeInstallPolicy(args: Extract<KtxSetupArgs, { command: 'run' }>): 'prompt' | 'auto' | 'never' {
|
|
if (args.yes) {
|
|
return 'auto';
|
|
}
|
|
return runtimeInstallPolicyFromFlags({ input: args.inputMode === 'disabled' ? false : true });
|
|
}
|
|
|
|
async function commitSetupConfigChanges(projectDir: string): Promise<void> {
|
|
const project = await loadKtxProject({ projectDir });
|
|
await project.git.commitFile('ktx.yaml', 'setup: update KTX project config', 'ktx setup', 'setup@ktx.local');
|
|
}
|
|
|
|
export async function runKtxSetup(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetupDeps = {}): Promise<number> {
|
|
try {
|
|
return await runKtxSetupInner(args, io, deps);
|
|
} catch (error) {
|
|
if (isKtxSetupExitError(error)) {
|
|
return 0;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetupDeps = {}): Promise<number> {
|
|
const setupUi = deps.setupUi ?? createKtxSetupUiAdapter();
|
|
setupUi.intro('KTX setup', io);
|
|
setupUi.note(KTX_DOCS_URL, '📚 Docs', io);
|
|
let entryAction: KtxSetupEntryAction | undefined;
|
|
let projectResult: Awaited<ReturnType<typeof runKtxSetupProjectStep>>;
|
|
let agentNextActions: string | undefined;
|
|
const canShowEntryMenu =
|
|
args.showEntryMenu === true &&
|
|
args.inputMode !== 'disabled' &&
|
|
!args.agents &&
|
|
(io.stdout.isTTY === true || deps.entryMenuDeps?.prompts !== undefined);
|
|
|
|
setupLoop: while (true) {
|
|
entryAction = undefined;
|
|
if (canShowEntryMenu) {
|
|
const status = await readKtxSetupStatus(args.projectDir, { cliVersion: args.cliVersion });
|
|
entryAction = (await runKtxSetupEntryMenu(status, deps.entryMenuDeps)).action;
|
|
if (entryAction === 'exit') {
|
|
(deps.entryMenuDeps?.prompts ?? createEntryMenuPromptAdapter()).cancel('Setup cancelled.');
|
|
return 0;
|
|
}
|
|
if (entryAction === 'status') {
|
|
io.stdout.write(formatKtxSetupStatus(status));
|
|
return 0;
|
|
}
|
|
if (entryAction === 'demo') {
|
|
return await runKtxSetupDemoFromEntryMenu(args, io, deps);
|
|
}
|
|
}
|
|
|
|
const projectMode = entryAction === 'new-project' ? 'prompt-new' : args.mode;
|
|
const projectStepStartedAt = performance.now();
|
|
projectResult = await runKtxSetupProjectStep(
|
|
{
|
|
projectDir: args.projectDir,
|
|
mode: projectMode,
|
|
inputMode: args.inputMode,
|
|
yes: args.yes,
|
|
allowBack: canShowEntryMenu,
|
|
},
|
|
io,
|
|
deps.project,
|
|
);
|
|
await recordSetupStep({
|
|
projectDir: projectResult.projectDir,
|
|
step: 'project',
|
|
status: projectResult.status,
|
|
startedAt: projectStepStartedAt,
|
|
io,
|
|
cliVersion: args.cliVersion,
|
|
});
|
|
|
|
if (projectResult.status === 'back') {
|
|
continue;
|
|
}
|
|
|
|
if (projectResult.status !== 'ready') {
|
|
return projectResult.status === 'cancelled' ? 0 : 1;
|
|
}
|
|
|
|
const agentsRequested = args.agents || entryAction === 'agents';
|
|
const currentStatus = await readKtxSetupStatus(projectResult.projectDir, { cliVersion: args.cliVersion });
|
|
let readyAction: string | undefined;
|
|
|
|
if (args.inputMode !== 'disabled' && !agentsRequested) {
|
|
const completion = classifyKtxSetupCompletion(currentStatus);
|
|
if (completion === 'ready') {
|
|
setupUi.note(formatNextStepLines().join('\n'), 'ktx is ready', io);
|
|
const choice = (await runKtxSetupReadyMenu(currentStatus, deps.readyMenuDeps)).action;
|
|
if (choice === 'exit') return 0;
|
|
readyAction = choice;
|
|
} else if (completion === 'needs-context') {
|
|
// Config is done; skip the re-walk and land straight on the build prompt.
|
|
readyAction = 'context';
|
|
} else if (completion === 'needs-agents') {
|
|
readyAction = 'agents';
|
|
}
|
|
// 'incomplete' → readyAction stays undefined → run the full setup walk.
|
|
}
|
|
|
|
const runOnly = readyAction;
|
|
const agentOnlySetup = agentsRequested || runOnly === 'agents';
|
|
const shouldRunModels = !runOnly || runOnly === 'models';
|
|
const shouldRunEmbeddings = !runOnly || runOnly === 'embeddings';
|
|
const shouldRunDatabases = !runOnly || runOnly === 'databases';
|
|
const shouldRunSources = !runOnly || runOnly === 'sources';
|
|
const shouldRunRuntime =
|
|
!agentOnlySetup && (!runOnly || runOnly === 'runtime' || runOnly === 'context');
|
|
const shouldRunContext = !agentOnlySetup && (!runOnly || runOnly === 'context');
|
|
const shouldRunAgents = agentsRequested || !runOnly || runOnly === 'agents';
|
|
const showPromptInstructions = projectResult.confirmedCreation !== true;
|
|
let skipSourcesFromDatabaseMenu = false;
|
|
|
|
const setupSteps: KtxSetupFlowStep[] = agentOnlySetup
|
|
? []
|
|
: ['models', 'embeddings', 'databases', 'sources', 'runtime', 'context'];
|
|
if (shouldRunAgents && args.skipAgents !== true) {
|
|
setupSteps.push('agents');
|
|
}
|
|
|
|
const forcePromptSteps = new Set<KtxSetupFlowStep>();
|
|
const isNavigableSetupStep = (step: KtxSetupFlowStep): boolean => {
|
|
if (step === 'models') return !args.skipLlm && shouldRunModels;
|
|
if (step === 'embeddings') return !args.skipEmbeddings && shouldRunEmbeddings;
|
|
if (step === 'databases') return !args.skipDatabases && shouldRunDatabases;
|
|
if (step === 'sources') {
|
|
return args.skipSources !== true && !skipSourcesFromDatabaseMenu && shouldRunSources;
|
|
}
|
|
if (step === 'runtime') return shouldRunRuntime;
|
|
if (step === 'context') return shouldRunContext;
|
|
return shouldRunAgents && args.skipAgents !== true;
|
|
};
|
|
const previousNavigableStepIndex = (currentIndex: number): number => {
|
|
for (let index = currentIndex - 1; index >= 0; index -= 1) {
|
|
const previousStep = setupSteps[index];
|
|
if (previousStep && isNavigableSetupStep(previousStep)) {
|
|
return index;
|
|
}
|
|
}
|
|
return -1;
|
|
};
|
|
|
|
for (let stepIndex = 0; stepIndex < setupSteps.length; ) {
|
|
const step = setupSteps[stepIndex];
|
|
if (!step) break;
|
|
|
|
const stepStartedAt = performance.now();
|
|
let stepResult: { status: KtxSetupFlowStatus; errorDetail?: string };
|
|
if (step === 'models') {
|
|
const modelRunner =
|
|
deps.model ?? ((modelArgs, modelIo) => runKtxSetupAnthropicModelStep(modelArgs, modelIo, deps.modelDeps));
|
|
stepResult = await modelRunner(
|
|
{
|
|
projectDir: projectResult.projectDir,
|
|
inputMode: args.inputMode,
|
|
...(args.llmBackend ? { llmBackend: args.llmBackend } : {}),
|
|
...(args.anthropicApiKeyEnv ? { anthropicApiKeyEnv: args.anthropicApiKeyEnv } : {}),
|
|
...(args.anthropicApiKeyFile ? { anthropicApiKeyFile: args.anthropicApiKeyFile } : {}),
|
|
...(args.llmModel ? { llmModel: args.llmModel } : {}),
|
|
...(args.vertexProject ? { vertexProject: args.vertexProject } : {}),
|
|
...(args.vertexLocation ? { vertexLocation: args.vertexLocation } : {}),
|
|
forcePrompt: forcePromptSteps.has('models') || runOnly === 'models',
|
|
showPromptInstructions,
|
|
skipLlm: args.skipLlm || !shouldRunModels,
|
|
},
|
|
io,
|
|
);
|
|
} else if (step === 'embeddings') {
|
|
const embeddingsRunner =
|
|
deps.embeddings ??
|
|
((embeddingArgs, embeddingIo) => runKtxSetupEmbeddingsStep(embeddingArgs, embeddingIo, deps.embeddingsDeps));
|
|
stepResult = await embeddingsRunner(
|
|
{
|
|
projectDir: projectResult.projectDir,
|
|
inputMode: args.inputMode,
|
|
cliVersion: args.cliVersion,
|
|
runtimeInstallPolicy: setupRuntimeInstallPolicy(args),
|
|
...(args.embeddingBackend ? { embeddingBackend: args.embeddingBackend } : {}),
|
|
...(args.embeddingApiKeyEnv ? { embeddingApiKeyEnv: args.embeddingApiKeyEnv } : {}),
|
|
...(args.embeddingApiKeyFile ? { embeddingApiKeyFile: args.embeddingApiKeyFile } : {}),
|
|
forcePrompt: forcePromptSteps.has('embeddings') || runOnly === 'embeddings',
|
|
showPromptInstructions,
|
|
skipEmbeddings: args.skipEmbeddings || !shouldRunEmbeddings,
|
|
},
|
|
io,
|
|
);
|
|
} else if (step === 'databases') {
|
|
const databasesRunner =
|
|
deps.databases ??
|
|
((databaseArgs, databaseIo) => runKtxSetupDatabasesStep(databaseArgs, databaseIo, deps.databasesDeps));
|
|
const databaseResult = await databasesRunner(
|
|
{
|
|
projectDir: projectResult.projectDir,
|
|
inputMode: args.inputMode,
|
|
yes: args.yes,
|
|
cliVersion: args.cliVersion,
|
|
runtimeInstallPolicy: setupRuntimeInstallPolicy(args),
|
|
...(args.databaseDrivers ? { databaseDrivers: args.databaseDrivers } : {}),
|
|
...(args.databaseConnectionIds ? { databaseConnectionIds: args.databaseConnectionIds } : {}),
|
|
...(args.databaseConnectionId ? { databaseConnectionId: args.databaseConnectionId } : {}),
|
|
...(args.databaseUrl ? { databaseUrl: args.databaseUrl } : {}),
|
|
databaseSchemas: args.databaseSchemas,
|
|
...(args.enableQueryHistory !== undefined ? { enableQueryHistory: args.enableQueryHistory } : {}),
|
|
...(args.disableQueryHistory !== undefined ? { disableQueryHistory: args.disableQueryHistory } : {}),
|
|
...(args.queryHistoryWindowDays !== undefined ? { queryHistoryWindowDays: args.queryHistoryWindowDays } : {}),
|
|
...(args.queryHistoryMinExecutions !== undefined
|
|
? { queryHistoryMinExecutions: args.queryHistoryMinExecutions }
|
|
: {}),
|
|
...(args.queryHistoryServiceAccountPatterns
|
|
? { queryHistoryServiceAccountPatterns: args.queryHistoryServiceAccountPatterns }
|
|
: {}),
|
|
...(args.queryHistoryRedactionPatterns
|
|
? { queryHistoryRedactionPatterns: args.queryHistoryRedactionPatterns }
|
|
: {}),
|
|
skipDatabases: args.skipDatabases || !shouldRunDatabases,
|
|
},
|
|
io,
|
|
);
|
|
skipSourcesFromDatabaseMenu = databaseResult.status === 'ready' && databaseResult.skipSources === true;
|
|
stepResult = databaseResult;
|
|
} else if (step === 'sources') {
|
|
const sourcesRunner =
|
|
deps.sources ?? ((sourceArgs, sourceIo) => runKtxSetupSourcesStep(sourceArgs, sourceIo, deps.sourcesDeps));
|
|
stepResult = await sourcesRunner(
|
|
{
|
|
projectDir: projectResult.projectDir,
|
|
inputMode: args.inputMode,
|
|
...(args.source ? { source: args.source } : {}),
|
|
...(args.sourceConnectionId ? { sourceConnectionId: args.sourceConnectionId } : {}),
|
|
...(args.sourcePath ? { sourcePath: args.sourcePath } : {}),
|
|
...(args.sourceGitUrl ? { sourceGitUrl: args.sourceGitUrl } : {}),
|
|
...(args.sourceBranch ? { sourceBranch: args.sourceBranch } : {}),
|
|
...(args.sourceSubpath ? { sourceSubpath: args.sourceSubpath } : {}),
|
|
...(args.sourceAuthTokenRef ? { sourceAuthTokenRef: args.sourceAuthTokenRef } : {}),
|
|
...(args.sourceUrl ? { sourceUrl: args.sourceUrl } : {}),
|
|
...(args.sourceApiKeyRef ? { sourceApiKeyRef: args.sourceApiKeyRef } : {}),
|
|
...(args.sourceClientId ? { sourceClientId: args.sourceClientId } : {}),
|
|
...(args.sourceClientSecretRef ? { sourceClientSecretRef: args.sourceClientSecretRef } : {}),
|
|
...(args.sourceWarehouseConnectionId ? { sourceWarehouseConnectionId: args.sourceWarehouseConnectionId } : {}),
|
|
...(args.sourceProjectName ? { sourceProjectName: args.sourceProjectName } : {}),
|
|
...(args.sourceProfilesPath ? { sourceProfilesPath: args.sourceProfilesPath } : {}),
|
|
...(args.sourceTarget ? { sourceTarget: args.sourceTarget } : {}),
|
|
...(args.metabaseDatabaseId !== undefined ? { metabaseDatabaseId: args.metabaseDatabaseId } : {}),
|
|
...(args.notionCrawlMode ? { notionCrawlMode: args.notionCrawlMode } : {}),
|
|
...(args.notionRootPageIds ? { notionRootPageIds: args.notionRootPageIds } : {}),
|
|
runInitialSourceIngest: args.runInitialSourceIngest ?? false,
|
|
skipSources: args.skipSources === true || !shouldRunSources || skipSourcesFromDatabaseMenu,
|
|
},
|
|
io,
|
|
);
|
|
} else if (step === 'runtime') {
|
|
const runtimeRunner =
|
|
deps.runtime ??
|
|
((runtimeArgs, runtimeIo) => runKtxSetupRuntimeStep(runtimeArgs, runtimeIo, deps.runtimeDeps));
|
|
stepResult = await runtimeRunner(
|
|
{
|
|
projectDir: projectResult.projectDir,
|
|
inputMode: args.inputMode,
|
|
cliVersion: args.cliVersion,
|
|
runtimeInstallPolicy: setupRuntimeInstallPolicy(args),
|
|
},
|
|
io,
|
|
);
|
|
} else if (step === 'context') {
|
|
const contextRunner =
|
|
deps.context ??
|
|
((contextArgs, contextIo) => runKtxSetupContextStep(contextArgs, contextIo, deps.contextDeps));
|
|
stepResult = await contextRunner(
|
|
{
|
|
projectDir: projectResult.projectDir,
|
|
inputMode: args.inputMode,
|
|
forcePrompt: forcePromptSteps.has('context') || runOnly === 'context',
|
|
allowEmpty: true,
|
|
cliVersion: args.cliVersion,
|
|
runtimeInstallPolicy: setupRuntimeInstallPolicy(args),
|
|
},
|
|
io,
|
|
);
|
|
} else {
|
|
const agentsRunner =
|
|
deps.agents ?? ((agentArgs, agentIo) => runKtxSetupAgentsStep(agentArgs, agentIo, deps.agentsDeps));
|
|
const agentResult = await agentsRunner(
|
|
{
|
|
projectDir: projectResult.projectDir,
|
|
inputMode:
|
|
args.inputMode === 'auto' && io.stdout.isTTY !== true && deps.agentsDeps?.prompts === undefined
|
|
? 'disabled'
|
|
: args.inputMode,
|
|
yes: args.yes,
|
|
agents: true,
|
|
...(args.target ? { target: args.target } : {}),
|
|
scope: args.agentScope ?? 'project',
|
|
mode: 'mcp',
|
|
skipAgents: false,
|
|
showNextActions: agentsRequested,
|
|
},
|
|
io,
|
|
);
|
|
stepResult = agentResult;
|
|
if (agentResult.status === 'ready') {
|
|
agentNextActions = agentResult.nextActions;
|
|
}
|
|
}
|
|
|
|
await recordSetupStep({
|
|
projectDir: projectResult.projectDir,
|
|
step,
|
|
status: stepResult.status,
|
|
startedAt: stepStartedAt,
|
|
io,
|
|
cliVersion: args.cliVersion,
|
|
...(stepResult.errorDetail ? { errorDetail: stepResult.errorDetail } : {}),
|
|
});
|
|
|
|
if (stepResult.status === 'failed') {
|
|
return 1;
|
|
}
|
|
if (stepResult.status === 'missing-input') {
|
|
return 1;
|
|
}
|
|
if (stepResult.status === 'back') {
|
|
const previousIndex = previousNavigableStepIndex(stepIndex);
|
|
if (previousIndex < 0) {
|
|
if (canShowEntryMenu) {
|
|
continue setupLoop;
|
|
}
|
|
return 0;
|
|
}
|
|
const previousStep = setupSteps[previousIndex];
|
|
if (previousStep) {
|
|
forcePromptSteps.add(previousStep);
|
|
}
|
|
stepIndex = previousIndex;
|
|
continue;
|
|
}
|
|
if (step === 'context' && stepResult.status !== 'ready') {
|
|
if (shouldRunAgents && args.skipAgents !== true) {
|
|
// Context isn't built, so skip agent install — but still reach the
|
|
// completion screen, which states readiness and points at `ktx ingest`.
|
|
break setupLoop;
|
|
}
|
|
}
|
|
|
|
forcePromptSteps.delete(step);
|
|
stepIndex += 1;
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
await commitSetupConfigChanges(projectResult.projectDir);
|
|
|
|
const status = await readKtxSetupStatus(projectResult.projectDir, { cliVersion: args.cliVersion });
|
|
const focusedOnAgents = args.agents || entryAction === 'agents';
|
|
if (!focusedOnAgents) {
|
|
if (shouldPrintConciseReadySummary(status)) {
|
|
setupUi.note(
|
|
formatKtxSetupCompletionSummary(status, { agentNextActions }),
|
|
agentNextActions ? 'Finish KTX agent setup' : 'KTX project ready',
|
|
io,
|
|
{
|
|
format: (line) => line,
|
|
},
|
|
);
|
|
} else {
|
|
setupUi.note(formatKtxSetupStatus(status).trimEnd(), 'Project status', io, {
|
|
format: (line) => line,
|
|
});
|
|
setupUi.note(
|
|
formatSetupNextStepLines({
|
|
setupReady: setupStatusReady(status),
|
|
hasContextTargets: setupHasContextTargets(status),
|
|
contextReady: setupContextReady(status),
|
|
agentIntegrationReady: status.agents.some((agent) => agent.ready),
|
|
}).join('\n'),
|
|
'What you can do next',
|
|
io,
|
|
);
|
|
}
|
|
}
|
|
return 0;
|
|
}
|