merge: resolve conflict with main's box-drawing formatting

Keep the confirmProjectDir helper extraction from this branch while
adopting the │ box-drawing prefixes added on main.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Luca Martial 2026-05-12 17:03:03 -07:00
commit ea10dd9f39
39 changed files with 1583 additions and 1313 deletions

View file

@ -10,18 +10,18 @@
"test": "node --test tests/*.test.mjs"
},
"dependencies": {
"fumadocs-core": "15.7.13",
"fumadocs-mdx": "11.10.1",
"fumadocs-ui": "15.7.13",
"next": "^15",
"fumadocs-core": "16.8.10",
"fumadocs-mdx": "15.0.4",
"fumadocs-ui": "16.8.10",
"next": "^16",
"react": "19.2.6",
"react-dom": "19.2.6"
},
"devDependencies": {
"@types/node": "^24.3.0",
"@types/node": "^25.7.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"typescript": "^5.9",
"typescript": "^6.0",
"@tailwindcss/postcss": "^4",
"tailwindcss": "^4"
}

View file

@ -4,7 +4,7 @@
"description": "Workspace root for ktx packages",
"private": true,
"type": "module",
"packageManager": "pnpm@10.28.0",
"packageManager": "pnpm@11.1.1",
"engines": {
"node": ">=22.0.0",
"pnpm": ">=10.20.0"
@ -36,9 +36,9 @@
"type-check": "pnpm --filter './packages/*' run type-check"
},
"devDependencies": {
"@types/node": "^24.3.0",
"typescript": "^5.9.3",
"vitest": "^4.0.18"
"@types/node": "^25.7.0",
"typescript": "^6.0.3",
"vitest": "^4.1.6"
},
"pnpm": {
"onlyBuiltDependencies": [

View file

@ -34,7 +34,7 @@
"type-check": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@clack/prompts": "1.3.0",
"@clack/prompts": "1.4.0",
"@commander-js/extra-typings": "14.0.0",
"@ktx/connector-bigquery": "workspace:*",
"@ktx/connector-clickhouse": "workspace:*",
@ -46,18 +46,18 @@
"@ktx/context": "workspace:*",
"@ktx/llm": "workspace:*",
"commander": "14.0.3",
"ink": "^7.0.1",
"react": "^19.2.5",
"ink": "^7.0.2",
"react": "^19.2.6",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^24.3.0",
"@types/node": "^25.7.0",
"@types/react": "^19.2.14",
"better-sqlite3": "^12.6.2",
"better-sqlite3": "^12.10.0",
"ink-testing-library": "^4.0.0",
"typescript": "^5.9.3",
"vitest": "^4.0.18"
"typescript": "^6.0.3",
"vitest": "^4.1.6"
},
"license": "Apache-2.0",
"repository": {

View file

@ -28,12 +28,12 @@ describe('prompt navigation helpers', () => {
'Name this PostgreSQL connection\nKTX will use this short name in commands and config. You can rename it now.',
),
).toBe(
'Name this PostgreSQL connection\n\nKTX will use this short name in commands and config. You can rename it now.\nPress Escape to go back.\n',
'Name this PostgreSQL connection\n\nKTX will use this short name in commands and config. You can rename it now.\nPress Escape to go back.\n',
);
});
it('adds a blank separator before compact text input values', () => {
expect(withTextInputNavigation('Project folder path')).toBe('Project folder path\nPress Escape to go back.\n');
expect(withTextInputNavigation('Project folder path')).toBe('Project folder path\nPress Escape to go back.\n');
});
it('normalizes already hinted text input prompts without duplicating the hint', () => {
@ -42,7 +42,19 @@ describe('prompt navigation helpers', () => {
'Name this PostgreSQL connection\nKTX will use this short name in commands and config. You can rename it now.\nPress Escape to go back.',
),
).toBe(
'Name this PostgreSQL connection\n\nKTX will use this short name in commands and config. You can rename it now.\nPress Escape to go back.\n',
'Name this PostgreSQL connection\n\nKTX will use this short name in commands and config. You can rename it now.\nPress Escape to go back.\n',
);
});
it('is idempotent when text input navigation is applied twice', () => {
const once = withTextInputNavigation('Project folder path');
expect(withTextInputNavigation(once)).toBe(once);
});
it('is idempotent when text input navigation with body is applied twice', () => {
const once = withTextInputNavigation(
'Name this PostgreSQL connection\nKTX will use this short name in commands and config.',
);
expect(withTextInputNavigation(once)).toBe(once);
});
});

View file

@ -6,6 +6,26 @@ function removeTrailingBlankLines(message: string): string {
return message.replace(/\n+$/, '');
}
function prefixContinuationLines(message: string): string {
const lines = message.split('\n');
if (lines.length <= 1) return message;
const [title, ...body] = lines;
let trailingEmptyCount = 0;
while (trailingEmptyCount < body.length && body[body.length - 1 - trailingEmptyCount] === '') {
trailingEmptyCount++;
}
const contentBody = trailingEmptyCount > 0 ? body.slice(0, -trailingEmptyCount) : body;
const trailingBody = trailingEmptyCount > 0 ? body.slice(-trailingEmptyCount) : [];
return [
title,
...contentBody.map((line) => {
const stripped = line.replace(/^│\s*/, '');
return stripped === '' ? '│' : `${stripped}`;
}),
...trailingBody,
].join('\n');
}
function withTextInputBodySpacing(message: string): string {
const normalized = removeTrailingBlankLines(message);
if (!normalized.includes('\n')) {
@ -39,7 +59,9 @@ export function withMultiselectNavigation(message: string): string {
export function withTextInputNavigation(message: string): string {
const messageWithoutHint = removeTrailingBlankLines(message)
.split('\n')
.filter((line) => line !== TEXT_INPUT_NAVIGATION_HINT)
.filter((line) => !line.includes(TEXT_INPUT_NAVIGATION_HINT))
.map((line) => line.replace(/^│\s*/, ''))
.join('\n');
return `${withTextInputBodySpacing(messageWithoutHint)}\n${TEXT_INPUT_NAVIGATION_HINT}\n`;
const full = `${withTextInputBodySpacing(messageWithoutHint)}\n${TEXT_INPUT_NAVIGATION_HINT}`;
return `${prefixContinuationLines(full)}\n│`;
}

View file

@ -2,7 +2,12 @@ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { dirname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { cancel, isCancel, multiselect, select } from '@clack/prompts';
import { loadKtxProject, markKtxSetupStepComplete, serializeKtxProjectConfig } from '@ktx/context/project';
import {
loadKtxProject,
markKtxSetupStateStepComplete,
serializeKtxProjectConfig,
stripKtxSetupCompletedSteps,
} from '@ktx/context/project';
import type { KtxCliIo } from './cli-runtime.js';
import { withMenuOptionsSpacing, withMultiselectNavigation } from './prompt-navigation.js';
import { withSetupInterruptConfirmation } from './setup-interrupt.js';
@ -360,7 +365,8 @@ async function installTarget(input: {
async function markAgentsComplete(projectDir: string): Promise<void> {
const project = await loadKtxProject({ projectDir });
await writeFile(project.configPath, serializeKtxProjectConfig(markKtxSetupStepComplete(project.config, 'agents')), 'utf-8');
await writeFile(project.configPath, serializeKtxProjectConfig(stripKtxSetupCompletedSteps(project.config)), 'utf-8');
await markKtxSetupStateStepComplete(projectDir, 'agents');
}
export async function runKtxSetupAgentsStep(
@ -369,7 +375,7 @@ export async function runKtxSetupAgentsStep(
deps: KtxSetupAgentsDeps = {},
): Promise<KtxSetupAgentsResult> {
if (args.skipAgents) {
io.stdout.write('Agent integration skipped.\n');
io.stdout.write('Agent integration skipped.\n');
return { status: 'skipped', projectDir: args.projectDir };
}
if (!args.agents && args.inputMode === 'disabled') {

View file

@ -5,8 +5,11 @@ import { cancel, isCancel, select } from '@clack/prompts';
import {
type KtxLocalProject,
loadKtxProject,
markKtxSetupStepComplete,
ktxSetupCompletedSteps,
markKtxSetupStateStepComplete,
readKtxSetupState,
serializeKtxProjectConfig,
stripKtxSetupCompletedSteps,
} from '@ktx/context/project';
import type { KtxCliIo } from './cli-runtime.js';
import { buildPublicIngestPlan } from './public-ingest.js';
@ -467,11 +470,8 @@ async function defaultVerifyContextReady(projectDir: string): Promise<KtxSetupCo
async function markContextComplete(projectDir: string): Promise<void> {
const project = await loadKtxProject({ projectDir });
await writeFile(
project.configPath,
serializeKtxProjectConfig(markKtxSetupStepComplete(project.config, 'context')),
'utf-8',
);
await writeFile(project.configPath, serializeKtxProjectConfig(stripKtxSetupCompletedSteps(project.config)), 'utf-8');
await markKtxSetupStateStepComplete(projectDir, 'context');
}
function writeBuildHeader(projectDir: string, runId: string, io: KtxCliIo): void {
@ -714,7 +714,8 @@ export async function runKtxSetupContextStep(
try {
const project = await loadKtxProject({ projectDir: args.projectDir });
const existingState = await readKtxSetupContextState(args.projectDir);
if (project.config.setup?.completed_steps.includes('context') === true && existingState.status === 'completed') {
const completedSteps = ktxSetupCompletedSteps(project.config, await readKtxSetupState(args.projectDir));
if (completedSteps.includes('context') && existingState.status === 'completed') {
return { status: 'ready', projectDir: args.projectDir, runId: existingState.runId ?? 'setup-context-completed' };
}

View file

@ -1,7 +1,7 @@
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { initKtxProject, parseKtxProjectConfig } from '@ktx/context/project';
import { initKtxProject, parseKtxProjectConfig, readKtxSetupState } from '@ktx/context/project';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
type KtxSetupDatabaseDriver,
@ -58,10 +58,10 @@ function connectionNamePrompt(label: string): string {
function textInputPrompt(message: string): string {
const normalized = message.replace(/\n+$/, '');
if (!normalized.includes('\n')) {
return `${normalized}\nPress Escape to go back.\n`;
return `${normalized}\nPress Escape to go back.\n`;
}
const [title, ...bodyLines] = normalized.split('\n');
return `${title}\n\n${bodyLines.join('\n')}\nPress Escape to go back.\n`;
return `${title}\n\n${bodyLines.join('\n')}\nPress Escape to go back.\n`;
}
const legacyHistoricSqlServiceAccountPatternsKey = ['serviceAccount', 'UserPatterns'].join('');
@ -1091,8 +1091,9 @@ describe('setup databases step', () => {
});
expect(config.setup).toEqual({
database_connection_ids: ['warehouse'],
completed_steps: ['databases'],
completed_steps: [],
});
expect((await readKtxSetupState(tempDir)).completed_steps).toContain('databases');
expect(io.stdout()).toContain('Primary source ready');
expect(io.stdout()).not.toContain('DATABASE_URL=');
});
@ -1129,8 +1130,9 @@ describe('setup databases step', () => {
});
expect(config.setup).toEqual({
database_connection_ids: ['warehouse'],
completed_steps: ['databases'],
completed_steps: [],
});
expect((await readKtxSetupState(tempDir)).completed_steps).toContain('databases');
});
it('selects multiple existing connections and validates each before recording setup ids', async () => {
@ -1178,7 +1180,8 @@ describe('setup databases step', () => {
expect(scanConnection).toHaveBeenCalledTimes(2);
const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8'));
expect(config.setup?.database_connection_ids).toEqual(['warehouse', 'analytics']);
expect(config.setup?.completed_steps).toContain('databases');
expect(config.setup?.completed_steps).toEqual([]);
expect((await readKtxSetupState(tempDir)).completed_steps).toContain('databases');
});
it('keeps the connection config but does not mark databases complete when scanning fails', async () => {

View file

@ -4,8 +4,10 @@ import type { HistoricSqlDialect } from '@ktx/context/ingest';
import {
type KtxProjectConnectionConfig,
loadKtxProject,
markKtxSetupStateStepComplete,
serializeKtxProjectConfig,
setKtxSetupDatabaseConnectionIds,
stripKtxSetupCompletedSteps,
} from '@ktx/context/project';
import type { KtxCliIo } from './cli-runtime.js';
import { runKtxConnection } from './connection.js';
@ -923,7 +925,7 @@ async function writeConnectionConfig(input: {
[input.connectionId]: input.connection,
},
};
await writeFile(project.configPath, serializeKtxProjectConfig(config), 'utf-8');
await writeFile(project.configPath, serializeKtxProjectConfig(stripKtxSetupCompletedSteps(config)), 'utf-8');
const historicSql =
typeof input.connection.historicSql === 'object' &&
@ -1076,25 +1078,28 @@ async function ensureHistoricSqlIngestDefaults(projectDir: string): Promise<void
}
await writeFile(
project.configPath,
serializeKtxProjectConfig({
...project.config,
ingest: {
...project.config.ingest,
adapters,
workUnits: {
...project.config.ingest.workUnits,
maxConcurrency,
serializeKtxProjectConfig(
stripKtxSetupCompletedSteps({
...project.config,
ingest: {
...project.config.ingest,
adapters,
workUnits: {
...project.config.ingest.workUnits,
maxConcurrency,
},
},
},
}),
}),
),
'utf-8',
);
}
async function markDatabasesComplete(projectDir: string, connectionIds: string[]): Promise<void> {
const project = await loadKtxProject({ projectDir });
const config = setKtxSetupDatabaseConnectionIds(project.config, unique(connectionIds), { complete: true });
await writeFile(project.configPath, serializeKtxProjectConfig(config), 'utf-8');
const config = setKtxSetupDatabaseConnectionIds(project.config, unique(connectionIds));
await writeFile(project.configPath, serializeKtxProjectConfig(stripKtxSetupCompletedSteps(config)), 'utf-8');
await markKtxSetupStateStepComplete(projectDir, 'databases');
}
async function maybeRunHistoricSqlSetupProbe(input: {
@ -1110,7 +1115,7 @@ async function maybeRunHistoricSqlSetupProbe(input: {
return;
}
input.io.stdout.write('Historic SQL probe...\n');
input.io.stdout.write('Historic SQL probe...\n');
const probe = input.deps.historicSqlProbe ?? defaultHistoricSqlProbe;
const result = await probe({
projectDir: input.projectDir,
@ -1118,10 +1123,10 @@ async function maybeRunHistoricSqlSetupProbe(input: {
dialect: 'postgres',
});
for (const line of result.lines) {
input.io.stdout.write(`${line}\n`);
input.io.stdout.write(`${line}\n`);
}
if (!result.ok) {
input.io.stdout.write('Setup written; first ingest run will fail until fixed.\n');
input.io.stdout.write('Setup written; first ingest run will fail until fixed.\n');
}
}
@ -1256,7 +1261,7 @@ async function chooseDrivers(
return 'back';
}
io.stdout.write('KTX cannot work without at least one primary source. Select a source or press Escape to go back.\n');
io.stdout.write('KTX cannot work without at least one primary source. Select a source or press Escape to go back.\n');
}
}
@ -1320,7 +1325,7 @@ export async function runKtxSetupDatabasesStep(
deps: KtxSetupDatabasesDeps = {},
): Promise<KtxSetupDatabasesResult> {
if (args.skipDatabases) {
io.stdout.write('Primary source setup skipped. KTX cannot work until you add a primary source.\n');
io.stdout.write('Primary source setup skipped. KTX cannot work until you add a primary source.\n');
return { status: 'skipped', projectDir: args.projectDir };
}
@ -1377,7 +1382,7 @@ export async function runKtxSetupDatabasesStep(
if (drivers === 'missing-input') return { status: 'missing-input', projectDir: args.projectDir };
if (drivers.length === 0) {
await markDatabasesComplete(args.projectDir, []);
io.stdout.write('KTX cannot work without a primary source.\n');
io.stdout.write('KTX cannot work without a primary source.\n');
return { status: 'skipped', projectDir: args.projectDir };
}

View file

@ -1,7 +1,7 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { initKtxProject, parseKtxProjectConfig } from '@ktx/context/project';
import { initKtxProject, parseKtxProjectConfig, readKtxSetupState } from '@ktx/context/project';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { type KtxSetupEmbeddingsPromptAdapter, runKtxSetupEmbeddingsStep } from './setup-embeddings.js';
@ -166,7 +166,8 @@ describe('setup embeddings step', () => {
sentenceTransformers: { base_url: 'managed:local-embeddings', pathPrefix: '' },
});
expect(config.scan.enrichment.embeddings).toMatchObject(config.ingest.embeddings);
expect(config.setup?.completed_steps).toContain('embeddings');
expect(config.setup?.completed_steps).toEqual(undefined);
expect((await readKtxSetupState(tempDir)).completed_steps).toContain('embeddings');
expect(io.stdout()).toContain(
'Testing local sentence-transformers embeddings (all-MiniLM-L6-v2, 384 dimensions). First run may take up to 60 seconds.',
);
@ -198,7 +199,7 @@ describe('setup embeddings step', () => {
await vi.waitFor(() => {
expect(io.stdout()).toContain(
'\r- Testing local sentence-transformers embeddings (all-MiniLM-L6-v2, 384 dimensions). First run may take up to 60 seconds.',
'\r- Testing local sentence-transformers embeddings (all-MiniLM-L6-v2, 384 dimensions). First run may take up to 60 seconds.',
);
});
@ -238,7 +239,8 @@ describe('setup embeddings step', () => {
sentenceTransformers: { base_url: 'managed:local-embeddings', pathPrefix: '' },
});
expect(config.scan.enrichment.embeddings).toMatchObject(config.ingest.embeddings);
expect(config.setup?.completed_steps).toContain('embeddings');
expect(config.setup?.completed_steps).toEqual(undefined);
expect((await readKtxSetupState(tempDir)).completed_steps).toContain('embeddings');
});
it('fails non-interactive local setup when the managed local embeddings runtime is missing', async () => {

View file

@ -4,9 +4,12 @@ import { resolveKtxConfigReference } from '@ktx/context/core';
import {
type KtxProjectConfig,
type KtxProjectEmbeddingConfig,
ktxSetupCompletedSteps,
loadKtxProject,
markKtxSetupStepComplete,
markKtxSetupStateStepComplete,
readKtxSetupState,
serializeKtxProjectConfig,
stripKtxSetupCompletedSteps,
} from '@ktx/context/project';
import { type KtxEmbeddingConfig, type KtxEmbeddingHealthCheckResult, runKtxEmbeddingHealthCheck } from '@ktx/llm';
import type { KtxCliIo } from './cli-runtime.js';
@ -111,9 +114,9 @@ function createPromptAdapter(): KtxSetupEmbeddingsPromptAdapter {
};
}
function hasCompletedEmbeddings(config: KtxProjectConfig): boolean {
async function hasCompletedEmbeddings(projectDir: string, config: KtxProjectConfig): Promise<boolean> {
return (
config.setup?.completed_steps.includes('embeddings') === true &&
ktxSetupCompletedSteps(config, await readKtxSetupState(projectDir)).includes('embeddings') &&
config.ingest.embeddings.backend !== 'none' &&
config.ingest.embeddings.backend !== 'deterministic' &&
typeof config.ingest.embeddings.model === 'string' &&
@ -187,7 +190,7 @@ function embeddingBackendDisplayName(backend: KtxSetupEmbeddingBackend): string
async function persistEmbeddingConfig(projectDir: string, embeddings: KtxProjectEmbeddingConfig): Promise<void> {
const project = await loadKtxProject({ projectDir });
const config = markKtxSetupStepComplete(
const config = stripKtxSetupCompletedSteps(
{
...project.config,
ingest: {
@ -202,9 +205,9 @@ async function persistEmbeddingConfig(projectDir: string, embeddings: KtxProject
},
},
},
'embeddings',
);
await writeFile(project.configPath, serializeKtxProjectConfig(config), 'utf-8');
await markKtxSetupStateStepComplete(projectDir, 'embeddings');
}
async function chooseCredentialRef(
@ -257,7 +260,7 @@ async function chooseCredentialRef(
}
if (choice === 'paste') {
io.stdout.write(
`${[
`${[
`KTX will save the key in .ktx/secrets/${backend}-api-key with local file permissions,`,
'then write a file: reference in ktx.yaml.',
].join(' ')}\n`,
@ -349,7 +352,7 @@ function healthCheckStartText(backend: KtxSetupEmbeddingBackend, model: string,
function startHealthCheckProgress(io: KtxCliIo, message: string): HealthCheckProgress {
if (io.stdout.isTTY !== true) {
io.stdout.write(`${message}\n`);
io.stdout.write(`${message}\n`);
const noop = () => undefined;
return {
succeed: noop,
@ -360,7 +363,7 @@ function startHealthCheckProgress(io: KtxCliIo, message: string): HealthCheckPro
let frameIndex = 0;
let stopped = false;
const writeFrame = () => {
io.stdout.write(`${CLEAR_CURRENT_LINE}${HEALTH_CHECK_SPINNER_FRAMES[frameIndex]} ${message}`);
io.stdout.write(`${CLEAR_CURRENT_LINE}${HEALTH_CHECK_SPINNER_FRAMES[frameIndex]} ${message}`);
};
writeFrame();
const interval = setInterval(() => {
@ -374,7 +377,7 @@ function startHealthCheckProgress(io: KtxCliIo, message: string): HealthCheckPro
}
stopped = true;
clearInterval(interval);
io.stdout.write(`${CLEAR_CURRENT_LINE}${finalMessage}\n`);
io.stdout.write(`${CLEAR_CURRENT_LINE}${finalMessage}\n`);
};
return {
@ -393,19 +396,19 @@ export async function runKtxSetupEmbeddingsStep(
deps: KtxSetupEmbeddingsDeps = {},
): Promise<KtxSetupEmbeddingsResult> {
if (args.skipEmbeddings) {
io.stdout.write('Embeddings setup skipped.\n');
io.stdout.write('Embeddings setup skipped.\n');
return { status: 'skipped', projectDir: args.projectDir };
}
const project = await loadKtxProject({ projectDir: args.projectDir });
if (
args.forcePrompt !== true &&
hasCompletedEmbeddings(project.config) &&
(await hasCompletedEmbeddings(args.projectDir, project.config)) &&
!args.embeddingBackend &&
!args.embeddingApiKeyEnv &&
!args.embeddingApiKeyFile
) {
io.stdout.write(`Embeddings ready: yes (${project.config.ingest.embeddings.model})\n`);
io.stdout.write(`Embeddings ready: yes (${project.config.ingest.embeddings.model})\n`);
return { status: 'ready', projectDir: args.projectDir };
}
@ -492,7 +495,7 @@ export async function runKtxSetupEmbeddingsStep(
credentialRef,
}),
);
io.stdout.write(`Embeddings ready: yes (${model}, ${dimensions} dimensions)\n`);
io.stdout.write(`Embeddings ready: yes (${model}, ${dimensions} dimensions)\n`);
return { status: 'ready', projectDir: args.projectDir };
}

View file

@ -1,7 +1,7 @@
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { initKtxProject, parseKtxProjectConfig } from '@ktx/context/project';
import { initKtxProject, parseKtxProjectConfig, readKtxSetupState } from '@ktx/context/project';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
BUNDLED_ANTHROPIC_MODELS,
@ -160,7 +160,8 @@ describe('setup Anthropic model step', () => {
promptCaching: { enabled: true },
});
expect(config.scan.enrichment.mode).toBe('llm');
expect(config.setup?.completed_steps).toContain('llm');
expect(config.setup?.completed_steps).toEqual(undefined);
expect((await readKtxSetupState(tempDir)).completed_steps).toContain('llm');
expect(io.stdout()).toContain('LLM ready: yes');
expect(io.stdout()).not.toContain('sk-ant-test');
});
@ -198,7 +199,8 @@ describe('setup Anthropic model step', () => {
},
models: { default: 'claude-sonnet-4-6' },
});
expect(config.setup?.completed_steps).toContain('llm');
expect(config.setup?.completed_steps).toEqual(undefined);
expect((await readKtxSetupState(tempDir)).completed_steps).toContain('llm');
expect(io.stdout()).not.toContain('sk-ant-file');
});
@ -310,7 +312,7 @@ describe('setup Anthropic model step', () => {
expect(result.status).toBe('ready');
expect(prompts.select).not.toHaveBeenCalledWith(expect.objectContaining({ message: 'Paste Anthropic API key now?' }));
expect(prompts.password).toHaveBeenCalledWith({
message: 'Anthropic API key\nPress Escape to go back.\n',
message: 'Anthropic API key\nPress Escape to go back.\n',
});
});
@ -462,7 +464,7 @@ describe('setup Anthropic model step', () => {
);
expect(prompts.text).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Anthropic model ID\nPress Escape to go back.\n',
message: 'Anthropic model ID\nPress Escape to go back.\n',
placeholder: 'claude-sonnet-4-6',
}),
);
@ -551,7 +553,8 @@ describe('setup Anthropic model step', () => {
expect(io.stderr()).toContain('Choose a different credential source or model, or Back.');
const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8'));
expect(config.llm.models.default).toBe('claude-sonnet-4-6');
expect(config.setup?.completed_steps).toContain('llm');
expect(config.setup?.completed_steps).toEqual(undefined);
expect((await readKtxSetupState(tempDir)).completed_steps).toContain('llm');
expect(io.stderr()).not.toContain('sk-ant-test');
});
@ -626,7 +629,7 @@ describe('setup Anthropic model step', () => {
expect(result.status).toBe('ready');
expect(prompts.password).toHaveBeenCalledWith({
message: 'Anthropic API key\nPress Escape to go back.\n',
message: 'Anthropic API key\nPress Escape to go back.\n',
});
await expect(readFile(join(tempDir, '.ktx/secrets/anthropic-api-key'), 'utf-8')).rejects.toMatchObject({
code: 'ENOENT',

View file

@ -6,8 +6,9 @@ import {
type KtxProjectConfig,
type KtxProjectLlmConfig,
loadKtxProject,
markKtxSetupStepComplete,
markKtxSetupStateStepComplete,
serializeKtxProjectConfig,
stripKtxSetupCompletedSteps,
} from '@ktx/context/project';
import { type KtxLlmConfig, type KtxLlmHealthCheckResult, runKtxLlmHealthCheck } from '@ktx/llm';
import type { KtxCliIo } from './cli-runtime.js';
@ -254,7 +255,7 @@ async function chooseCredentialRef(
const prompts = deps.prompts ?? createPromptAdapter();
if (args.showPromptInstructions !== false) {
io.stdout.write(
'Use Up/Down to move, Enter to confirm the current selection, choose Back to return to the previous step, Ctrl+C to exit.\n',
'Use Up/Down to move, Enter to confirm the current selection, choose Back to return to the previous step, Ctrl+C to exit.\n',
);
}
while (true) {
@ -271,7 +272,7 @@ async function chooseCredentialRef(
}
if (choice === 'paste') {
io.stdout.write(
'KTX will save the key in .ktx/secrets/anthropic-api-key with local file permissions, then write a file: reference in ktx.yaml.\n',
'KTX will save the key in .ktx/secrets/anthropic-api-key with local file permissions, then write a file: reference in ktx.yaml.\n',
);
const value = await prompts.password({ message: withTextInputNavigation('Anthropic API key') });
if (value === undefined) {
@ -361,7 +362,7 @@ async function chooseModel(
async function persistLlmConfig(projectDir: string, credentialRef: string, model: string): Promise<void> {
const project = await loadKtxProject({ projectDir });
const config = markKtxSetupStepComplete(
const config = stripKtxSetupCompletedSteps(
{
...project.config,
llm: buildProjectLlmConfig(project.config.llm, credentialRef, model),
@ -373,9 +374,9 @@ async function persistLlmConfig(projectDir: string, credentialRef: string, model
},
},
},
'llm',
);
await writeFile(project.configPath, serializeKtxProjectConfig(config), 'utf-8');
await markKtxSetupStateStepComplete(projectDir, 'llm');
}
function buildInteractiveRetryArgs(args: KtxSetupModelArgs): KtxSetupModelArgs {
@ -393,7 +394,7 @@ export async function runKtxSetupAnthropicModelStep(
deps: KtxSetupModelDeps = {},
): Promise<KtxSetupModelResult> {
if (args.skipLlm) {
io.stdout.write('LLM setup skipped.\n');
io.stdout.write('LLM setup skipped.\n');
return { status: 'skipped', projectDir: args.projectDir };
}
@ -405,7 +406,7 @@ export async function runKtxSetupAnthropicModelStep(
!args.anthropicApiKeyFile &&
!args.anthropicModel
) {
io.stdout.write(`LLM ready: yes (${project.config.llm.models.default})\n`);
io.stdout.write(`LLM ready: yes (${project.config.llm.models.default})\n`);
return { status: 'ready', projectDir: args.projectDir };
}
@ -438,7 +439,7 @@ export async function runKtxSetupAnthropicModelStep(
const health = await healthCheck(buildHealthConfig(credential.value, model.model));
if (health.ok) {
await persistLlmConfig(args.projectDir, credential.ref, model.model);
io.stdout.write(`LLM ready: yes (${model.model})\n`);
io.stdout.write(`LLM ready: yes (${model.model})\n`);
return { status: 'ready', projectDir: args.projectDir };
}

View file

@ -1,7 +1,7 @@
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { initKtxProject, parseKtxProjectConfig } from '@ktx/context/project';
import { initKtxProject, parseKtxProjectConfig, readKtxSetupState } from '@ktx/context/project';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { type KtxSetupProjectPromptAdapter, runKtxSetupProjectStep } from './setup-project.js';
@ -60,7 +60,8 @@ describe('setup project step', () => {
expect(result.status).toBe('ready');
expect(result.projectDir).toBe(projectDir);
const config = parseKtxProjectConfig(await readFile(join(projectDir, 'ktx.yaml'), 'utf-8'));
expect(config.setup?.completed_steps).toEqual(['project']);
expect(config.setup?.completed_steps).toEqual(undefined);
expect(await readKtxSetupState(projectDir)).toEqual({ completed_steps: ['project'] });
await expect(stat(join(projectDir, '.git'))).resolves.toBeDefined();
await expect(readFile(join(projectDir, '.ktx/.gitignore'), 'utf-8')).resolves.toContain('secrets/');
expect(testIo.stdout()).toContain(`Project: ${projectDir}`);
@ -93,8 +94,9 @@ describe('setup project step', () => {
const config = parseKtxProjectConfig(await readFile(join(projectDir, 'ktx.yaml'), 'utf-8'));
expect(config.setup).toEqual({
database_connection_ids: ['warehouse'],
completed_steps: ['llm', 'project'],
completed_steps: [],
});
expect(await readKtxSetupState(projectDir)).toEqual({ completed_steps: ['llm', 'project'] });
});
it('creates a missing auto-mode project only when --yes is present in no-input mode', async () => {
@ -151,7 +153,8 @@ describe('setup project step', () => {
);
expect(prompts.text).not.toHaveBeenCalled();
const config = parseKtxProjectConfig(await readFile(join(projectDir, 'ktx.yaml'), 'utf-8'));
expect(config.setup?.completed_steps).toEqual(['project']);
expect(config.setup?.completed_steps).toEqual(undefined);
expect(await readKtxSetupState(projectDir)).toEqual({ completed_steps: ['project'] });
});
it('offers an absolute default destination for a new project folder', async () => {
@ -183,7 +186,7 @@ describe('setup project step', () => {
);
expect(prompts.text).not.toHaveBeenCalled();
expect(result.status === 'ready' ? result.project.config.project : '').toBe('ktx-project');
expect(testIo.stdout()).toContain(`KTX will create:\n ${projectDir}`);
expect(testIo.stdout()).toContain(`KTX will create:\n ${projectDir}`);
await expect(stat(join(projectDir, 'ktx.yaml'))).resolves.toBeDefined();
});
@ -202,7 +205,7 @@ describe('setup project step', () => {
expect(result.projectDir).toBe(projectDir);
expect(prompts.text).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Project folder path\nPress Escape to go back.\n',
message: 'Project folder path\nPress Escape to go back.\n',
placeholder: './analytics-ktx, ~/analytics-ktx, or /Users/you/projects/analytics-ktx',
}),
);

View file

@ -5,11 +5,15 @@ import { basename, join, resolve } from 'node:path';
import { cancel, isCancel, select, text } from '@clack/prompts';
import {
initKtxProject,
ktxSetupCompletedSteps,
type KtxLocalProject,
loadKtxProject,
markKtxSetupStepComplete,
markKtxSetupStateStepComplete,
mergeKtxSetupGitignoreEntries,
readKtxSetupState,
serializeKtxProjectConfig,
stripKtxSetupCompletedSteps,
writeKtxSetupState,
} from '@ktx/context/project';
import type { KtxCliIo } from './cli-runtime.js';
import { withMenuOptionsSpacing, withTextInputNavigation } from './prompt-navigation.js';
@ -143,7 +147,7 @@ async function confirmProjectDir(
return { status: 'confirmed', confirmedCreation: true };
}
io.stdout.write(`KTX will create:\n ${selectedDir}\n`);
io.stdout.write(`KTX will create:\n ${selectedDir}\n`);
const action = await prompts.select({
message: `Create KTX project at ${selectedDir}?`,
options: [
@ -166,8 +170,11 @@ async function normalizeSetupGitignore(projectDir: string): Promise<void> {
}
async function persistProjectStep(project: KtxLocalProject): Promise<KtxLocalProject> {
const config = markKtxSetupStepComplete(project.config, 'project');
const completedSteps = ktxSetupCompletedSteps(project.config, await readKtxSetupState(project.projectDir));
const config = stripKtxSetupCompletedSteps(project.config);
await writeFile(project.configPath, serializeKtxProjectConfig(config), 'utf-8');
await writeKtxSetupState(project.projectDir, { completed_steps: completedSteps });
await markKtxSetupStateStepComplete(project.projectDir, 'project');
await normalizeSetupGitignore(project.projectDir);
return await loadKtxProject({ projectDir: project.projectDir });
}
@ -185,7 +192,7 @@ async function loadExistingProject(projectDir: string, deps: KtxSetupProjectDeps
}
function printProjectSummary(io: KtxCliIo, projectDir: string): void {
io.stdout.write(`Project: ${projectDir}\n`);
io.stdout.write(`Project: ${projectDir}\n`);
}
async function promptForNewProjectDir(
@ -197,8 +204,8 @@ async function promptForNewProjectDir(
const defaultProjectDir = join(projectDir, DEFAULT_NEW_PROJECT_FOLDER_NAME);
while (true) {
io.stdout.write(`Relative paths are resolved from:\n ${projectDir}\n`);
io.stdout.write(`Home paths are resolved from:\n ${homeDir}\n`);
io.stdout.write(`Relative paths are resolved from:\n ${projectDir}\n`);
io.stdout.write(`Home paths are resolved from:\n ${homeDir}\n`);
const destinationChoice = await prompts.select({
message: 'Where should KTX create the project?',
options: [
@ -324,7 +331,7 @@ export async function runKtxSetupProjectStep(
const prompts = deps.prompts ?? createClackSetupProjectPromptAdapter();
const defaultProjectDir = join(projectDir, DEFAULT_NEW_PROJECT_FOLDER_NAME);
io.stdout.write(
'Use Up/Down to move, Enter to confirm the current selection, choose Back to return to the previous step, Ctrl+C to exit.\n',
'Use Up/Down to move, Enter to confirm the current selection, choose Back to return to the previous step, Ctrl+C to exit.\n',
);
while (true) {
const choice = await prompts.select({
@ -369,8 +376,8 @@ export async function runKtxSetupProjectStep(
}
if (choice === 'new-custom') {
io.stdout.write(`Relative paths are resolved from:\n ${projectDir}\n`);
io.stdout.write(`Home paths are resolved from:\n ${homeDir}\n`);
io.stdout.write(`Relative paths are resolved from:\n ${projectDir}\n`);
io.stdout.write(`Home paths are resolved from:\n ${homeDir}\n`);
const rawPath = await prompts.text({
message: withTextInputNavigation('Project folder path'),
placeholder: './analytics-ktx, ~/analytics-ktx, or /Users/you/projects/analytics-ktx',

View file

@ -5,6 +5,7 @@ import {
initKtxProject,
type KtxProjectConnectionConfig,
parseKtxProjectConfig,
readKtxSetupState,
serializeKtxProjectConfig,
} from '@ktx/context/project';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@ -65,10 +66,10 @@ function connectionNamePrompt(label: string): string {
function textInputPrompt(message: string): string {
const normalized = message.replace(/\n+$/, '');
if (!normalized.includes('\n')) {
return `${normalized}\nPress Escape to go back.\n`;
return `${normalized}\nPress Escape to go back.\n`;
}
const [title, ...bodyLines] = normalized.split('\n');
return `${title}\n\n${bodyLines.join('\n')}\nPress Escape to go back.\n`;
return `${title}\n\n${bodyLines.join('\n')}\nPress Escape to go back.\n`;
}
describe('setup sources step', () => {
@ -136,7 +137,8 @@ describe('setup sources step', () => {
projectDir,
});
expect((await readConfig()).setup?.completed_steps).toContain('sources');
expect((await readConfig()).setup?.completed_steps).toEqual(undefined);
expect((await readKtxSetupState(projectDir)).completed_steps).toContain('sources');
expect(io.stdout()).toContain('Context source setup skipped.');
});
@ -169,7 +171,8 @@ describe('setup sources step', () => {
source_dir: '/repo/dbt',
project_name: 'analytics',
});
expect(config.setup?.completed_steps).toContain('sources');
expect(config.setup?.completed_steps).toEqual([]);
expect((await readKtxSetupState(projectDir)).completed_steps).toContain('sources');
expect(runInitialIngest).toHaveBeenCalledWith(projectDir, 'analytics_dbt', io.io, { inputMode: 'disabled' });
});

View file

@ -23,8 +23,9 @@ import {
type KtxProjectConfig,
type KtxProjectConnectionConfig,
loadKtxProject,
markKtxSetupStepComplete,
markKtxSetupStateStepComplete,
serializeKtxProjectConfig,
stripKtxSetupCompletedSteps,
} from '@ktx/context/project';
import type { KtxCliIo } from './cli-runtime.js';
import { runKtxConnectionMapping } from './commands/connection-mapping.js';
@ -333,7 +334,7 @@ function fileRepoUrl(sourceDir: string): string {
async function writeProjectConfig(projectDir: string, config: KtxProjectConfig): Promise<void> {
const project = await loadKtxProject({ projectDir });
await writeFile(project.configPath, serializeKtxProjectConfig(config), 'utf-8');
await writeFile(project.configPath, serializeKtxProjectConfig(stripKtxSetupCompletedSteps(config)), 'utf-8');
}
async function writeSourceConnection(
@ -360,7 +361,7 @@ async function writeSourceConnection(
: [...project.config.ingest.adapters, adapter],
},
};
await writeFile(project.configPath, serializeKtxProjectConfig(config), 'utf-8');
await writeFile(project.configPath, serializeKtxProjectConfig(stripKtxSetupCompletedSteps(config)), 'utf-8');
return async () => {
const latest = await loadKtxProject({ projectDir });
const connections = { ...latest.config.connections };
@ -399,11 +400,8 @@ async function ensureSourceAdapterEnabled(projectDir: string, source: KtxSetupSo
async function markSourcesComplete(projectDir: string): Promise<void> {
const project = await loadKtxProject({ projectDir });
await writeFile(
project.configPath,
serializeKtxProjectConfig(markKtxSetupStepComplete(project.config, 'sources')),
'utf-8',
);
await writeFile(project.configPath, serializeKtxProjectConfig(stripKtxSetupCompletedSteps(project.config)), 'utf-8');
await markKtxSetupStateStepComplete(projectDir, 'sources');
}
function hasPrimarySource(config: KtxProjectConfig): boolean {
@ -701,7 +699,7 @@ async function runInitialSourceIngestWithRecovery(input: {
deps: KtxSetupSourcesDeps;
}): Promise<'ready' | 'continue' | 'back' | 'failed'> {
while (true) {
input.io.stdout.write(`Building context from ${input.connectionId}. Large sources can take a while.\n`);
input.io.stdout.write(`Building context from ${input.connectionId}. Large sources can take a while.\n`);
const ingestCode = await (input.deps.runInitialIngest ?? defaultRunInitialIngest)(
input.args.projectDir,
input.connectionId,
@ -729,8 +727,8 @@ async function runInitialSourceIngestWithRecovery(input: {
continue;
}
if (action === 'continue') {
input.io.stdout.write(`Context source saved without a completed context build for ${input.connectionId}.\n`);
input.io.stdout.write(`Run later: ktx ingest ${input.connectionId}\n`);
input.io.stdout.write(`Context source saved without a completed context build for ${input.connectionId}.\n`);
input.io.stdout.write(`Run later: ktx ingest ${input.connectionId}\n`);
return 'continue';
}
return 'back';
@ -1357,7 +1355,7 @@ export async function runKtxSetupSourcesStep(
try {
if (args.skipSources) {
await markSourcesComplete(args.projectDir);
io.stdout.write('Context source setup skipped.\n');
io.stdout.write('Context source setup skipped.\n');
return { status: 'skipped', projectDir: args.projectDir };
}
@ -1370,7 +1368,7 @@ export async function runKtxSetupSourcesStep(
return { status: 'failed', projectDir: args.projectDir };
}
if (args.inputMode !== 'disabled') {
io.stdout.write(`${message}\n`);
io.stdout.write(`${message}\n`);
return { status: 'skipped', projectDir: args.projectDir };
}
}
@ -1394,7 +1392,7 @@ export async function runKtxSetupSourcesStep(
return { status: 'missing-input', projectDir: args.projectDir };
}
await markSourcesComplete(args.projectDir);
io.stdout.write('No context sources selected.\n');
io.stdout.write('No context sources selected.\n');
return { status: 'skipped', projectDir: args.projectDir };
}
@ -1467,7 +1465,7 @@ export async function runKtxSetupSourcesStep(
break;
}
} else {
io.stdout.write(`Context source ${connectionId} saved. It will be built during the context build step.\n`);
io.stdout.write(`Context source ${connectionId} saved. It will be built during the context build step.\n`);
}
readyConnectionIds.push(connectionId);
}

View file

@ -715,7 +715,7 @@ describe('setup status', () => {
expect(projectPrompts.text).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Project folder path\nPress Escape to go back.\n',
message: 'Project folder path\nPress Escape to go back.\n',
placeholder: './analytics-ktx, ~/analytics-ktx, or /Users/you/projects/analytics-ktx',
}),
);
@ -839,7 +839,10 @@ describe('setup status', () => {
).resolves.toBe(0);
await expect(stat(join(tempDir, 'ktx.yaml'))).resolves.toBeDefined();
expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).toContain('completed_steps:');
expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).not.toContain('completed_steps:');
await expect(readFile(join(tempDir, '.ktx', 'setup', 'state.json'), 'utf-8')).resolves.toBe(
`${JSON.stringify({ completed_steps: ['project', 'sources'] }, null, 2)}\n`,
);
expect(testIo.stdout()).toContain('KTX setup');
expect(testIo.stdout()).toContain(`Project: ${tempDir}`);
expect(testIo.stdout()).toContain('Project ready: yes');

View file

@ -2,7 +2,13 @@ import { existsSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { cancel, isCancel, select } from '@clack/prompts';
import { getLatestLocalIngestStatus, savedMemoryCountsForReport } from '@ktx/context/ingest';
import { ktxLocalStateDbPath, loadKtxProject, type KtxLocalProject } from '@ktx/context/project';
import {
ktxLocalStateDbPath,
ktxSetupCompletedSteps,
loadKtxProject,
readKtxSetupState,
type KtxLocalProject,
} from '@ktx/context/project';
import type { KtxCliIo } from './cli-runtime.js';
import { formatSetupNextStepLines } from './next-steps.js';
import { isKtxSetupExitError, withSetupInterruptConfirmation } from './setup-interrupt.js';
@ -291,7 +297,7 @@ export async function readKtxSetupStatus(projectDir: string): Promise<KtxSetupSt
};
embeddings.ready = embeddingsReady(embeddings);
const completedSteps = project.config.setup?.completed_steps ?? [];
const completedSteps = ktxSetupCompletedSteps(project.config, await readKtxSetupState(resolvedProjectDir));
const contextState = await readKtxSetupContextState(resolvedProjectDir);
const setupContextStatus = setupContextStatusFromState(contextState, {
completedStep: completedSteps.includes('context'),

View file

@ -26,13 +26,13 @@
"type-check": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@google-cloud/bigquery": "^8.1.1",
"@google-cloud/bigquery": "^8.3.1",
"@ktx/context": "workspace:*"
},
"devDependencies": {
"@types/node": "^24.3.0",
"typescript": "^5.9.3",
"vitest": "^4.0.18"
"@types/node": "^25.7.0",
"typescript": "^6.0.3",
"vitest": "^4.1.6"
},
"license": "Apache-2.0",
"repository": {

View file

@ -26,13 +26,13 @@
"type-check": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@clickhouse/client": "^1.18.2",
"@clickhouse/client": "^1.18.4",
"@ktx/context": "workspace:*"
},
"devDependencies": {
"@types/node": "^24.3.0",
"typescript": "^5.9.3",
"vitest": "^4.0.18"
"@types/node": "^25.7.0",
"typescript": "^6.0.3",
"vitest": "^4.1.6"
},
"license": "Apache-2.0",
"repository": {

View file

@ -27,12 +27,12 @@
},
"dependencies": {
"@ktx/context": "workspace:*",
"mysql2": "^3.18.1"
"mysql2": "^3.22.3"
},
"devDependencies": {
"@types/node": "^24.3.0",
"typescript": "^5.9.3",
"vitest": "^4.0.18"
"@types/node": "^25.7.0",
"typescript": "^6.0.3",
"vitest": "^4.1.6"
},
"license": "Apache-2.0",
"repository": {

View file

@ -27,13 +27,13 @@
},
"dependencies": {
"@ktx/context": "workspace:*",
"pg": "^8.19.0"
"pg": "^8.20.0"
},
"devDependencies": {
"@types/node": "^24.3.0",
"@types/pg": "^8.16.0",
"typescript": "^5.9.3",
"vitest": "^4.0.18"
"@types/node": "^25.7.0",
"@types/pg": "^8.20.0",
"typescript": "^6.0.3",
"vitest": "^4.1.6"
},
"license": "Apache-2.0",
"repository": {

View file

@ -27,12 +27,12 @@
},
"dependencies": {
"@ktx/context": "workspace:*",
"snowflake-sdk": "^2.3.4"
"snowflake-sdk": "^2.4.1"
},
"devDependencies": {
"@types/node": "^24.3.0",
"typescript": "^5.9.3",
"vitest": "^4.0.18"
"@types/node": "^25.7.0",
"typescript": "^6.0.3",
"vitest": "^4.1.6"
},
"license": "Apache-2.0",
"repository": {

View file

@ -27,13 +27,13 @@
},
"dependencies": {
"@ktx/context": "workspace:*",
"better-sqlite3": "^12.6.2"
"better-sqlite3": "^12.10.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^24.3.0",
"typescript": "^5.9.3",
"vitest": "^4.0.18"
"@types/node": "^25.7.0",
"typescript": "^6.0.3",
"vitest": "^4.1.6"
},
"license": "Apache-2.0",
"repository": {

View file

@ -27,13 +27,13 @@
},
"dependencies": {
"@ktx/context": "workspace:*",
"mssql": "^12.2.0"
"mssql": "^12.5.2"
},
"devDependencies": {
"@types/mssql": "^9.1.8",
"@types/node": "^24.3.0",
"typescript": "^5.9.3",
"vitest": "^4.0.18"
"@types/mssql": "^12.3.0",
"@types/node": "^25.7.0",
"typescript": "^6.0.3",
"vitest": "^4.1.6"
},
"license": "Apache-2.0",
"repository": {

View file

@ -130,30 +130,30 @@
},
"dependencies": {
"@ktx/llm": "workspace:*",
"@looker/sdk": "^26.6.1",
"@looker/sdk-node": "^26.6.1",
"@looker/sdk": "^26.8.0",
"@looker/sdk-node": "^26.8.0",
"@looker/sdk-rtl": "^21.6.5",
"@modelcontextprotocol/sdk": "^1.27.1",
"@notionhq/client": "^5.20.0",
"ai": "^6.0.168",
"better-sqlite3": "^12.6.2",
"handlebars": "^4.7.8",
"@modelcontextprotocol/sdk": "^1.29.0",
"@notionhq/client": "^5.21.0",
"ai": "^6.0.180",
"better-sqlite3": "^12.10.0",
"handlebars": "^4.7.9",
"lookml-parser": "7.1.0",
"minimatch": "^10.2.4",
"minimatch": "^10.2.5",
"p-limit": "^7.3.0",
"pg": "^8.19.0",
"simple-git": "3.32.2",
"yaml": "^2.8.2",
"zod": "^4.1.13"
"pg": "^8.20.0",
"simple-git": "3.36.0",
"yaml": "^2.9.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@electric-sql/pglite": "^0.4.5",
"@electric-sql/pglite-socket": "^0.1.5",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^24.3.0",
"@types/pg": "^8.16.0",
"typescript": "^5.9.3",
"vitest": "^4.0.18"
"@types/node": "^25.7.0",
"@types/pg": "^8.20.0",
"typescript": "^6.0.3",
"vitest": "^4.1.6"
},
"license": "Apache-2.0",
"repository": {

View file

@ -1,19 +1,34 @@
import { type SimpleGit, simpleGit } from 'simple-git';
const PRE_COMMIT_GIT_ENV = [
const SANITIZED_GIT_ENV_KEYS = [
'EDITOR',
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
'GIT_CONFIG',
'GIT_CONFIG_COUNT',
'GIT_CONFIG_GLOBAL',
'GIT_CONFIG_PARAMETERS',
'GIT_CONFIG_SYSTEM',
'GIT_DIR',
'GIT_EDITOR',
'GIT_EXEC_PATH',
'GIT_INDEX_FILE',
'GIT_OBJECT_DIRECTORY',
'GIT_PAGER',
'GIT_PREFIX',
'GIT_QUARANTINE_PATH',
'GIT_SEQUENCE_EDITOR',
'GIT_SSH',
'GIT_SSH_COMMAND',
'GIT_TEMPLATE_DIR',
'GIT_WORK_TREE',
'PAGER',
'SSH_ASKPASS',
'VISUAL',
] as const;
export function createSimpleGit(baseDir?: string): SimpleGit {
const env = { ...process.env };
for (const key of PRE_COMMIT_GIT_ENV) {
for (const key of SANITIZED_GIT_ENV_KEYS) {
delete env[key];
}
return simpleGit(baseDir).env(env);

View file

@ -75,7 +75,7 @@ export interface KtxProjectConnectionConfig {
export interface KtxProjectSetupConfig {
database_connection_ids: string[];
completed_steps: string[];
completed_steps?: string[];
}
export interface KtxProjectConfig {

View file

@ -27,7 +27,12 @@ export { initKtxProject, loadKtxProject } from './project.js';
export type { KtxSetupStep } from './setup-config.js';
export {
KTX_SETUP_STEPS,
markKtxSetupStepComplete,
ktxSetupCompletedSteps,
ktxSetupStatePath,
markKtxSetupStateStepComplete,
mergeKtxSetupGitignoreEntries,
readKtxSetupState,
setKtxSetupDatabaseConnectionIds,
stripKtxSetupCompletedSteps,
writeKtxSetupState,
} from './setup-config.js';

View file

@ -1,43 +1,40 @@
import { describe, expect, it } from 'vitest';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { buildDefaultKtxProjectConfig } from './config.js';
import {
markKtxSetupStepComplete,
ktxSetupCompletedSteps,
markKtxSetupStateStepComplete,
mergeKtxSetupGitignoreEntries,
readKtxSetupState,
setKtxSetupDatabaseConnectionIds,
stripKtxSetupCompletedSteps,
} from './setup-config.js';
describe('KTX setup config helpers', () => {
it('marks setup steps complete without duplicating existing state', () => {
const config = buildDefaultKtxProjectConfig('warehouse');
let tempDir: string;
const withProject = markKtxSetupStepComplete(config, 'project');
const withProjectAgain = markKtxSetupStepComplete(withProject, 'project');
const withLlm = markKtxSetupStepComplete(withProjectAgain, 'llm');
const withContext = markKtxSetupStepComplete(withLlm, 'context');
expect(withProject.setup).toEqual({
database_connection_ids: [],
completed_steps: ['project'],
});
expect(withProjectAgain.setup?.completed_steps).toEqual(['project']);
expect(withLlm.setup?.completed_steps).toEqual(['project', 'llm']);
expect(withContext.setup?.completed_steps).toEqual(['project', 'llm', 'context']);
expect(config.setup).toBeUndefined();
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'ktx-setup-state-'));
});
it('preserves database connection ids while marking a step complete', () => {
const config = {
...buildDefaultKtxProjectConfig('warehouse'),
setup: {
database_connection_ids: ['warehouse'],
completed_steps: ['databases'],
},
};
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
expect(markKtxSetupStepComplete(config, 'project').setup).toEqual({
database_connection_ids: ['warehouse'],
completed_steps: ['databases', 'project'],
it('marks setup steps complete in local state without duplicating existing state', async () => {
await markKtxSetupStateStepComplete(tempDir, 'project');
await markKtxSetupStateStepComplete(tempDir, 'project');
await markKtxSetupStateStepComplete(tempDir, 'llm');
await markKtxSetupStateStepComplete(tempDir, 'context');
expect(await readKtxSetupState(tempDir)).toEqual({
completed_steps: ['project', 'llm', 'context'],
});
await expect(readFile(join(tempDir, '.ktx', 'setup', 'state.json'), 'utf-8')).resolves.toBe(
`${JSON.stringify({ completed_steps: ['project', 'llm', 'context'] }, null, 2)}\n`,
);
});
it('sets setup database connection ids without duplicates', () => {
@ -47,22 +44,38 @@ describe('KTX setup config helpers', () => {
expect(withDatabases.setup).toEqual({
database_connection_ids: ['warehouse', 'analytics'],
completed_steps: [],
});
expect(config.setup).toBeUndefined();
});
it('marks databases complete only when requested', () => {
const config = markKtxSetupStepComplete(buildDefaultKtxProjectConfig('warehouse'), 'project');
it('strips setup completed steps while preserving database connection ids', () => {
const config = {
...buildDefaultKtxProjectConfig('warehouse'),
setup: {
database_connection_ids: ['warehouse'],
completed_steps: ['project', 'databases'],
},
};
const withDatabases = setKtxSetupDatabaseConnectionIds(config, ['warehouse'], { complete: true });
const withDatabasesAgain = setKtxSetupDatabaseConnectionIds(withDatabases, ['warehouse'], { complete: true });
expect(withDatabases.setup).toEqual({
expect(stripKtxSetupCompletedSteps(config).setup).toEqual({
database_connection_ids: ['warehouse'],
completed_steps: ['project', 'databases'],
});
expect(withDatabasesAgain.setup).toEqual(withDatabases.setup);
});
it('combines legacy config setup steps with local state for reads', () => {
const config = {
...buildDefaultKtxProjectConfig('warehouse'),
setup: {
database_connection_ids: ['warehouse'],
completed_steps: ['project', 'databases'],
},
};
expect(ktxSetupCompletedSteps(config, { completed_steps: ['databases', 'sources'] })).toEqual([
'project',
'databases',
'sources',
]);
});
it('merges setup-local gitignore entries without removing existing lines', () => {

View file

@ -1,9 +1,15 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { KtxProjectConfig } from './config.js';
export const KTX_SETUP_STEPS = ['project', 'llm', 'embeddings', 'databases', 'sources', 'context', 'agents'] as const;
export type KtxSetupStep = (typeof KTX_SETUP_STEPS)[number];
export interface KtxSetupState {
completed_steps: KtxSetupStep[];
}
const SETUP_GITIGNORE_ENTRIES = [
'cache/',
'db.sqlite',
@ -14,14 +20,67 @@ const SETUP_GITIGNORE_ENTRIES = [
'agents/',
] as const;
export function markKtxSetupStepComplete(config: KtxProjectConfig, step: KtxSetupStep): KtxProjectConfig {
const databaseConnectionIds = config.setup?.database_connection_ids ?? [];
const completedSteps = config.setup?.completed_steps ?? [];
function isKtxSetupStep(value: unknown): value is KtxSetupStep {
return typeof value === 'string' && (KTX_SETUP_STEPS as readonly string[]).includes(value);
}
function uniqueSetupSteps(steps: unknown): KtxSetupStep[] {
if (!Array.isArray(steps)) {
return [];
}
return [...new Set(steps.filter(isKtxSetupStep))];
}
export function ktxSetupStatePath(projectDir: string): string {
return join(projectDir, '.ktx', 'setup', 'state.json');
}
export async function readKtxSetupState(projectDir: string): Promise<KtxSetupState> {
try {
const parsed = JSON.parse(await readFile(ktxSetupStatePath(projectDir), 'utf-8')) as Record<string, unknown>;
return { completed_steps: uniqueSetupSteps(parsed.completed_steps) };
} catch (error) {
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
return { completed_steps: [] };
}
throw error;
}
}
export async function writeKtxSetupState(projectDir: string, state: KtxSetupState): Promise<void> {
await mkdir(join(projectDir, '.ktx', 'setup'), { recursive: true });
await writeFile(
ktxSetupStatePath(projectDir),
`${JSON.stringify({ completed_steps: uniqueSetupSteps(state.completed_steps) }, null, 2)}\n`,
'utf-8',
);
}
export async function markKtxSetupStateStepComplete(projectDir: string, step: KtxSetupStep): Promise<KtxSetupState> {
const state = await readKtxSetupState(projectDir);
const completedSteps = state.completed_steps.includes(step) ? state.completed_steps : [...state.completed_steps, step];
const nextState = { completed_steps: completedSteps };
await writeKtxSetupState(projectDir, nextState);
return nextState;
}
export function ktxSetupCompletedSteps(config: KtxProjectConfig, state: KtxSetupState): KtxSetupStep[] {
return uniqueSetupSteps([...(config.setup?.completed_steps ?? []), ...state.completed_steps]);
}
export function stripKtxSetupCompletedSteps(config: KtxProjectConfig): KtxProjectConfig {
if (!config.setup) {
return config;
}
const databaseConnectionIds = config.setup.database_connection_ids ?? [];
if (databaseConnectionIds.length === 0) {
const { setup: _setup, ...withoutSetup } = config;
return withoutSetup;
}
return {
...config,
setup: {
database_connection_ids: [...databaseConnectionIds],
completed_steps: completedSteps.includes(step) ? [...completedSteps] : [...completedSteps, step],
},
};
}
@ -29,20 +88,14 @@ export function markKtxSetupStepComplete(config: KtxProjectConfig, step: KtxSetu
export function setKtxSetupDatabaseConnectionIds(
config: KtxProjectConfig,
connectionIds: string[],
options: { complete?: boolean } = {},
): KtxProjectConfig {
const uniqueConnectionIds = [...new Set(connectionIds.filter((connectionId) => connectionId.trim().length > 0))];
const completedSteps = config.setup?.completed_steps ?? [];
const nextCompletedSteps =
options.complete === true && !completedSteps.includes('databases')
? [...completedSteps, 'databases']
: [...completedSteps];
return {
...config,
setup: {
database_connection_ids: uniqueConnectionIds,
completed_steps: nextCompletedSteps,
...(config.setup?.completed_steps ? { completed_steps: [...config.setup.completed_steps] } : {}),
},
};
}

View file

@ -26,16 +26,16 @@
"type-check": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@ai-sdk/anthropic": "3.0.71",
"@ai-sdk/anthropic": "3.0.77",
"@ai-sdk/devtools": "0.0.17",
"@ai-sdk/google-vertex": "^4.0.112",
"ai": "^6.0.168",
"openai": "^6.25.0"
"@ai-sdk/google-vertex": "^4.0.128",
"ai": "^6.0.180",
"openai": "^6.37.0"
},
"devDependencies": {
"@types/node": "^24.3.0",
"typescript": "^5.9.3",
"vitest": "^4.0.18"
"@types/node": "^25.7.0",
"typescript": "^6.0.3",
"vitest": "^4.1.6"
},
"license": "Apache-2.0",
"repository": {

2244
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -11,4 +11,8 @@ injectWorkspacePackages: true
syncInjectedDepsAfterScripts:
- build
shamefullyHoist: false
verifyDepsBeforeRun: install
verifyDepsBeforeRun: false
allowBuilds:
better-sqlite3: true
esbuild: true
sharp: true

View file

@ -9,6 +9,7 @@ import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import {
npmSmokePackageJson,
npmSmokePnpmWorkspaceYaml,
packageArtifactLayout,
} from './package-artifacts.mjs';
@ -280,6 +281,7 @@ async function prepareCleanInstall(layout, cleanInstallDir) {
await assertPathExists(layout.cliTarball, '@ktx/cli tarball');
await mkdir(cleanInstallDir, { recursive: true });
await writeFile(join(cleanInstallDir, 'package.json'), `${JSON.stringify(npmSmokePackageJson(layout), null, 2)}\n`);
await writeFile(join(cleanInstallDir, 'pnpm-workspace.yaml'), npmSmokePnpmWorkspaceYaml());
await run('pnpm', ['install'], { cwd: cleanInstallDir, timeout: 120_000 }).then((result) =>
requireSuccess('pnpm install clean artifact project', result),
);

View file

@ -9,6 +9,7 @@ import {
PUBLIC_NPM_PACKAGE_NAME,
PUBLIC_NPM_PACKAGE_VERSION,
} from './build-public-npm-package.mjs';
import { npmSmokePnpmWorkspaceYaml } from './package-artifacts.mjs';
const execFileAsync = promisify(execFile);
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
@ -249,6 +250,7 @@ async function writeSmokePackage(projectDir, tarballPath) {
2,
)}\n`,
);
await writeFile(join(projectDir, 'pnpm-workspace.yaml'), npmSmokePnpmWorkspaceYaml());
}
export async function runLocalEmbeddingsRuntimeSmoke(options = {}) {

View file

@ -462,12 +462,13 @@ export function npmSmokePackageJson(layout) {
devDependencies: {
'better-sqlite3': '^12.6.2',
},
pnpm: {
onlyBuiltDependencies: ['better-sqlite3'],
},
};
}
export function npmSmokePnpmWorkspaceYaml() {
return ['packages:', ' - "."', 'allowBuilds:', ' better-sqlite3: true', ''].join('\n');
}
export function npmVerifySource() {
return `
const cli = await import('@kaelio/ktx');
@ -1111,6 +1112,7 @@ async function verifyNpmArtifacts(layout, tmpRoot) {
join(projectDir, 'package.json'),
`${JSON.stringify(npmSmokePackageJson(layout), null, 2)}\n`,
);
await writeFile(join(projectDir, 'pnpm-workspace.yaml'), npmSmokePnpmWorkspaceYaml());
await writeFile(join(projectDir, 'verify-npm.mjs'), npmVerifySource());
await writeFile(join(projectDir, 'verify-installed-cli.mjs'), npmRuntimeSmokeSource());
await writeFile(join(projectDir, 'verify-installed-cli-commands.mjs'), npmCliSmokeSource());
@ -1131,6 +1133,7 @@ async function verifyNpmCliArtifacts(layout, tmpRoot) {
const projectDir = join(tmpRoot, 'npm-cli-clean-install');
await mkdir(projectDir, { recursive: true });
await writeFile(join(projectDir, 'package.json'), `${JSON.stringify(npmSmokePackageJson(layout), null, 2)}\n`);
await writeFile(join(projectDir, 'pnpm-workspace.yaml'), npmSmokePnpmWorkspaceYaml());
await writeFile(join(projectDir, 'verify-installed-cli-commands.mjs'), npmCliSmokeSource());
await runCommand('pnpm', ['install'], { cwd: projectDir });

View file

@ -19,6 +19,7 @@ import {
npmCliSmokeSource,
npmRuntimeSmokeSource,
npmSmokePackageJson,
npmSmokePnpmWorkspaceYaml,
npmVerifySource,
packageArtifactLayout,
packageReleaseMetadata,
@ -422,6 +423,10 @@ describe('verification snippets', () => {
assert.deepEqual(packageJson.devDependencies, {
'better-sqlite3': '^12.6.2',
});
assert.equal(
npmSmokePnpmWorkspaceYaml(),
['packages:', ' - "."', 'allowBuilds:', ' better-sqlite3: true', ''].join('\n'),
);
});
it('exposes manifest verification as a package artifact command', async () => {