feat(cli): add ktx dev schema to emit ktx.yaml JSON Schema

Annotates the Zod config schema with .describe() text on every field and
adds generateKtxProjectConfigJsonSchema() plus a ktx dev schema command
that prints (or writes) a draft-07 JSON Schema for editors and LLM agents.
This commit is contained in:
Andrey Avtomonov 2026-05-14 16:15:20 +02:00
parent b3be54e3fa
commit bd4b41f681
4 changed files with 299 additions and 110 deletions

View file

@ -46,5 +46,23 @@ export function registerDevCommands(program: Command, context: KtxCliCommandCont
}, },
); );
dev
.command('schema')
.description('Print a JSON Schema describing ktx.yaml (for editors and LLM agents)')
.option('--output <file>', 'Write the schema to a file instead of stdout')
.action(async (options: { output?: string }) => {
const { generateKtxProjectConfigJsonSchema } = await import('@ktx/context/project');
const json = `${JSON.stringify(generateKtxProjectConfigJsonSchema(), null, 2)}\n`;
if (options.output) {
const { writeFile } = await import('node:fs/promises');
const target = resolve(options.output);
await writeFile(target, json, 'utf8');
context.io.stdout.write(`Wrote ${target}\n`);
} else {
context.io.stdout.write(json);
}
context.setExitCode(0);
});
registerRuntimeCommands(dev, context); registerRuntimeCommands(dev, context);
} }

View file

@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
buildDefaultKtxProjectConfig, buildDefaultKtxProjectConfig,
generateKtxProjectConfigJsonSchema,
parseKtxProjectConfig, parseKtxProjectConfig,
serializeKtxProjectConfig, serializeKtxProjectConfig,
validateKtxProjectConfig, validateKtxProjectConfig,
@ -485,3 +486,49 @@ scan:
}); });
}); });
}); });
describe('generateKtxProjectConfigJsonSchema', () => {
const schema = generateKtxProjectConfigJsonSchema();
it('emits draft-07 metadata', () => {
expect(schema.$schema).toBe('http://json-schema.org/draft-07/schema#');
expect(schema.$id).toBe('https://ktx.dev/schemas/ktx-project-config.json');
expect(schema.title).toBe('ktx.yaml');
expect(schema.type).toBe('object');
});
it('exposes every top-level ktx.yaml section under properties', () => {
const properties = schema.properties as Record<string, unknown>;
expect(Object.keys(properties).sort()).toEqual(
['agent', 'connections', 'ingest', 'llm', 'memory', 'project', 'scan', 'setup', 'storage'].sort(),
);
});
it('marks "project" as required', () => {
expect(schema.required).toEqual(expect.arrayContaining(['project']));
});
it('carries .describe() text on top-level fields', () => {
const properties = schema.properties as Record<string, { description?: string }>;
expect(properties.project?.description).toMatch(/Project identifier/);
expect(properties.llm?.description).toMatch(/LLM/);
expect(properties.scan?.description).toMatch(/Schema-scan/);
});
it('propagates enum values through to nested fields', () => {
const llm = (schema.properties as Record<string, { properties?: Record<string, unknown> }>).llm;
const provider = llm?.properties?.provider as { properties?: Record<string, unknown> };
const backend = provider?.properties?.backend as { enum?: readonly string[] };
expect(backend?.enum).toEqual(['none', 'anthropic', 'vertex', 'gateway']);
const storage = (schema.properties as Record<string, { properties?: Record<string, unknown> }>).storage;
const state = storage?.properties?.state as { enum?: readonly string[] };
expect(state?.enum).toEqual(['sqlite', 'postgres']);
});
it('carries descriptions on deeply nested leaves', () => {
const scan = (schema.properties as Record<string, { properties?: Record<string, unknown> }>).scan;
const relationships = scan?.properties?.relationships as { properties?: Record<string, { description?: string }> };
expect(relationships?.properties?.acceptThreshold?.description).toMatch(/auto-accepted/);
});
});

View file

@ -19,137 +19,247 @@ const DEPRECATED_KEY_HINTS: Record<string, string> = {
'scan.enrichment.embeddings.provider': 'use scan.enrichment.embeddings.backend', 'scan.enrichment.embeddings.provider': 'use scan.enrichment.embeddings.backend',
}; };
const apiCredentialsSchema = z.strictObject({ const apiCredentialsSchema = z
api_key: z.string().min(1).optional(), .strictObject({
base_url: z.string().min(1).optional(), api_key: z.string().min(1).optional().describe('API key for the provider. Read from this value or the provider-specific environment variable.'),
}); base_url: z.string().min(1).optional().describe('Override the provider\'s default API base URL (e.g. a proxy or self-hosted gateway).'),
})
.describe('API credentials block: optional key and base URL for an LLM or embedding provider.');
const vertexProviderSchema = z.strictObject({ const vertexProviderSchema = z
project: z.string().min(1).optional(), .strictObject({
location: z.string().default(''), project: z.string().min(1).optional().describe('Google Cloud project ID hosting the Vertex AI endpoint.'),
}); location: z.string().default('').describe('Vertex AI region (e.g. "us-east5"). Empty string falls back to the SDK default.'),
})
.describe('Google Vertex AI provider configuration.');
const sentenceTransformersSchema = z.strictObject({ const sentenceTransformersSchema = z
base_url: z.string().default(''), .strictObject({
pathPrefix: z.string().optional(), base_url: z.string().default('').describe('Base URL of the sentence-transformers HTTP server. Empty string uses the managed local runtime.'),
}); pathPrefix: z.string().optional().describe('Optional URL path prefix prepended to embedding requests.'),
})
.describe('Sentence-transformers embedding server configuration.');
const llmProviderSchema = z.strictObject({ const llmProviderSchema = z
backend: z.enum(KTX_LLM_BACKENDS).default('none'), .strictObject({
vertex: vertexProviderSchema.optional(), backend: z
anthropic: apiCredentialsSchema.optional(), .enum(KTX_LLM_BACKENDS)
gateway: apiCredentialsSchema.optional(), .default('none')
}); .describe('LLM provider backend. "none" disables LLM features; "anthropic" / "vertex" / "gateway" require the matching nested credentials block.'),
vertex: vertexProviderSchema.optional().describe('Vertex AI credentials, used when backend is "vertex".'),
anthropic: apiCredentialsSchema.optional().describe('Anthropic API credentials, used when backend is "anthropic".'),
gateway: apiCredentialsSchema.optional().describe('AI Gateway credentials, used when backend is "gateway".'),
})
.describe('LLM provider selection and credentials.');
const promptCachingSchema = z.strictObject({ const promptCachingSchema = z
enabled: z.boolean().optional(), .strictObject({
systemTtl: z.enum(KTX_PROMPT_CACHE_TTLS).optional(), enabled: z.boolean().optional().describe('Master switch for Anthropic-style prompt caching. When omitted, the backend\'s default applies.'),
toolsTtl: z.enum(KTX_PROMPT_CACHE_TTLS).optional(), systemTtl: z.enum(KTX_PROMPT_CACHE_TTLS).optional().describe('Cache TTL for the system prompt segment ("5m" or "1h").'),
historyTtl: z.enum(KTX_PROMPT_CACHE_TTLS).optional(), toolsTtl: z.enum(KTX_PROMPT_CACHE_TTLS).optional().describe('Cache TTL for the tools/schema segment ("5m" or "1h").'),
vertexFallbackTo5m: z.boolean().optional(), historyTtl: z.enum(KTX_PROMPT_CACHE_TTLS).optional().describe('Cache TTL for conversation-history cache breakpoints ("5m" or "1h").'),
}); vertexFallbackTo5m: z.boolean().optional().describe('When true, transparently downgrade 1h TTLs to 5m on Vertex, which does not support 1h caching.'),
})
.describe('Prompt-caching tunables for Anthropic-compatible providers.');
const llmSchema = z.strictObject({ const llmSchema = z
provider: llmProviderSchema.prefault({}), .strictObject({
models: z.partialRecord(z.enum(KTX_MODEL_ROLES), z.string().min(1)).default({}), provider: llmProviderSchema.prefault({}).describe('LLM provider backend and credentials.'),
promptCaching: promptCachingSchema.optional(), models: z
}); .partialRecord(z.enum(KTX_MODEL_ROLES), z.string().min(1))
.default({})
.describe('Per-role model overrides keyed by KTX model role (e.g. "default", "triage"). Values are provider-specific model identifiers.'),
promptCaching: promptCachingSchema.optional().describe('Optional prompt-caching tunables.'),
})
.describe('LLM provider, per-role model overrides, and prompt-caching tunables.');
const embeddingSchema = z.strictObject({ const embeddingSchema = z
backend: z.enum(KTX_EMBEDDING_BACKENDS).default('deterministic'), .strictObject({
model: z.string().min(1).optional(), backend: z
dimensions: z.int().positive().default(8), .enum(KTX_EMBEDDING_BACKENDS)
openai: apiCredentialsSchema.optional(), .default('deterministic')
sentenceTransformers: sentenceTransformersSchema.optional(), .describe('Embedding backend. "deterministic" is a built-in hash-based vector for offline use; "openai" and "sentence-transformers" call out to those providers; "none" disables embeddings.'),
batchSize: z.int().positive().optional(), model: z.string().min(1).optional().describe('Provider-specific embedding model identifier (e.g. "text-embedding-3-small"). Ignored by the "deterministic" backend.'),
}); dimensions: z.int().positive().default(8).describe('Embedding vector dimensionality. Must match the chosen model when using a real provider.'),
openai: apiCredentialsSchema.optional().describe('OpenAI credentials, used when backend is "openai".'),
sentenceTransformers: sentenceTransformersSchema.optional().describe('Sentence-transformers server config, used when backend is "sentence-transformers".'),
batchSize: z.int().positive().optional().describe('Number of texts per embedding API call. Omit to use the backend default.'),
})
.describe('Embedding backend, model, and provider credentials.');
const workUnitsSchema = z.strictObject({ const workUnitsSchema = z
stepBudget: z.int().positive().default(40), .strictObject({
maxConcurrency: z.int().positive().default(1), stepBudget: z.int().positive().default(40).describe('Maximum number of agent steps allowed per work unit before it is force-terminated.'),
failureMode: z.enum(KTX_WORK_UNIT_FAILURE_MODES).default('continue'), maxConcurrency: z.int().positive().default(1).describe('Maximum number of work units run concurrently during ingest.'),
}); failureMode: z
.enum(KTX_WORK_UNIT_FAILURE_MODES)
.default('continue')
.describe('Behavior when a work unit fails: "abort" stops the whole ingest run; "continue" records the failure and keeps going.'),
})
.describe('Concurrency and failure handling for ingest work units.');
const ingestSchema = z.strictObject({ const ingestSchema = z
adapters: z.array(z.string().min(1)).default([]), .strictObject({
embeddings: embeddingSchema.prefault({ backend: 'deterministic', model: 'deterministic' }), adapters: z
workUnits: workUnitsSchema.prefault({}), .array(z.string().min(1))
}); .default([])
.describe('Ingest adapter identifiers to run (e.g. "metabase", "looker", "historic-sql"). Empty array means no adapters are run.'),
embeddings: embeddingSchema
.prefault({ backend: 'deterministic', model: 'deterministic' })
.describe('Embedding configuration used when ingest adapters need to embed documents.'),
workUnits: workUnitsSchema.prefault({}).describe('Concurrency and failure handling for ingest work units.'),
})
.describe('Ingest pipeline configuration: adapters, embeddings, and work-unit policy.');
const scanEnrichmentSchema = z.strictObject({ const scanEnrichmentSchema = z
mode: z.enum(KTX_ENRICHMENT_MODES).default('none'), .strictObject({
embeddings: embeddingSchema.optional(), mode: z
}); .enum(KTX_ENRICHMENT_MODES)
.default('none')
.describe('Column/table enrichment mode. "none" disables enrichment; "deterministic" uses local heuristics; "llm" calls the configured LLM provider.'),
embeddings: embeddingSchema.optional().describe('Optional embedding override for enrichment-time vectorization. Falls back to ingest.embeddings when omitted.'),
})
.describe('Schema-scan enrichment: how columns and tables are described.');
const scanRelationshipsSchema = z.strictObject({ const scanRelationshipsSchema = z
enabled: z.boolean().default(true), .strictObject({
llmProposals: z.boolean().default(true), enabled: z.boolean().default(true).describe('Master switch for relationship discovery during scan.'),
validationRequiredForManifest: z.boolean().default(true), llmProposals: z.boolean().default(true).describe('When true, propose relationships using the configured LLM in addition to deterministic candidates.'),
acceptThreshold: z.number().min(0).max(1).default(0.85), validationRequiredForManifest: z
reviewThreshold: z.number().min(0).max(1).default(0.55), .boolean()
maxLlmTablesPerBatch: z.int().positive().default(40), .default(true)
maxCandidatesPerColumn: z.int().positive().default(25), .describe('When true, only relationships that pass database-side validation are written to the manifest.'),
profileSampleRows: z.int().positive().default(10000), acceptThreshold: z
validationConcurrency: z.int().positive().default(4), .number()
validationBudget: z.union([z.literal('all'), z.int().nonnegative()]).optional(), .min(0)
}); .max(1)
.default(0.85)
.describe('Confidence score (01) at or above which an LLM-proposed relationship is auto-accepted into the manifest.'),
reviewThreshold: z
.number()
.min(0)
.max(1)
.default(0.55)
.describe('Confidence score (01) at or above which a proposal is surfaced for human review (but not auto-accepted).'),
maxLlmTablesPerBatch: z
.int()
.positive()
.default(40)
.describe('Maximum number of tables included in a single LLM relationship-proposal batch.'),
maxCandidatesPerColumn: z
.int()
.positive()
.default(25)
.describe('Maximum number of candidate join partners considered per column during relationship discovery.'),
profileSampleRows: z.int().positive().default(10000).describe('Number of rows sampled per table when profiling values for relationship inference.'),
validationConcurrency: z.int().positive().default(4).describe('Number of relationship validation queries run in parallel against the database.'),
validationBudget: z
.union([z.literal('all'), z.int().nonnegative()])
.optional()
.describe('Cap on validation queries per scan run. Use "all" for unlimited, an integer for a hard cap, or omit for the runtime default.'),
})
.describe('Schema-scan relationship discovery and validation tunables.');
const scanSchema = z.strictObject({ const scanSchema = z
enrichment: scanEnrichmentSchema.prefault({}), .strictObject({
relationships: scanRelationshipsSchema.prefault({}), enrichment: scanEnrichmentSchema.prefault({}).describe('Column/table enrichment configuration.'),
}); relationships: scanRelationshipsSchema.prefault({}).describe('Relationship discovery and validation configuration.'),
})
.describe('Schema-scan configuration: enrichment and relationship discovery.');
const setupSchema = z const setupSchema = z
.strictObject({ .strictObject({
database_connection_ids: z.array(z.string().min(1)).default([]), database_connection_ids: z
completed_steps: z.unknown().optional(), .array(z.string().min(1))
.default([])
.describe('Connection IDs (keys of the top-level `connections` map) that the setup wizard treats as the project\'s primary databases.'),
completed_steps: z
.unknown()
.optional()
.describe('Deprecated. Accepted for backward compatibility but ignored; KTX no longer tracks setup progress here.'),
}) })
.transform(({ database_connection_ids }) => ({ database_connection_ids })); .transform(({ database_connection_ids }) => ({ database_connection_ids }))
.describe('Setup-wizard state captured during `ktx setup`.');
const storageGitSchema = z.strictObject({ const storageGitSchema = z
auto_commit: z.boolean().default(true), .strictObject({
author: z.string().min(1).default('ktx <ktx@example.com>'), auto_commit: z.boolean().default(true).describe('When true, KTX automatically commits state changes to the local Git-backed store.'),
}); author: z
.string()
.min(1)
.default('ktx <ktx@example.com>')
.describe('Git author identity used for auto-commits, in standard "Name <email>" form.'),
})
.describe('Git-backed storage commit policy.');
const storageSchema = z.strictObject({ const storageSchema = z
state: z.enum(KTX_STORAGE_STATES).default('sqlite'), .strictObject({
search: z.enum(KTX_SEARCH_BACKENDS).default('sqlite-fts5'), state: z
git: storageGitSchema.prefault({}), .enum(KTX_STORAGE_STATES)
}); .default('sqlite')
.describe('Backend for KTX state storage. "sqlite" uses .ktx/db.sqlite; "postgres" expects a configured Postgres connection.'),
search: z
.enum(KTX_SEARCH_BACKENDS)
.default('sqlite-fts5')
.describe('Backend for search indexes. "sqlite-fts5" uses SQLite FTS5; "postgres-hybrid" uses Postgres lexical + vector hybrid search.'),
git: storageGitSchema.prefault({}).describe('Git-backed storage commit policy.'),
})
.describe('Storage backends and commit policy for KTX state and search indexes.');
const connectionSchema = z.looseObject({ const connectionSchema = z
driver: z.string().min(1).optional(), .looseObject({
url: z.string().optional(), driver: z.string().min(1).optional().describe('Connector driver identifier (e.g. "postgres", "bigquery", "snowflake").'),
}); url: z.string().optional().describe('Connection URL or DSN. Format depends on the driver; may contain environment-variable references.'),
})
.describe('A single database/connector connection entry. Additional driver-specific fields are accepted and passed through.');
const agentSchema = z.strictObject({ const agentSchema = z
run_research: z .strictObject({
.strictObject({ run_research: z
enabled: z.boolean().default(false), .strictObject({
max_iterations: z.number().int().nonnegative().default(20), enabled: z.boolean().default(false).describe('Master switch for the research agent.'),
default_toolset: z.array(z.string().min(1)).default(['sl_query', 'wiki_search', 'sl_read_source']), max_iterations: z
}) .number()
.prefault({}), .int()
}); .nonnegative()
.default(20)
.describe('Maximum number of tool-call iterations the research agent may take per run.'),
default_toolset: z
.array(z.string().min(1))
.default(['sl_query', 'wiki_search', 'sl_read_source'])
.describe('Default list of tool identifiers exposed to the research agent.'),
})
.prefault({})
.describe('Research-agent configuration.'),
})
.describe('Agent feature configuration.');
const memorySchema = z.strictObject({ const memorySchema = z
auto_commit: z.boolean().default(true), .strictObject({
}); auto_commit: z.boolean().default(true).describe('When true, KTX automatically commits memory updates to the Git-backed store.'),
})
.describe('Memory subsystem configuration.');
const ktxProjectConfigSchema = z.strictObject({ const ktxProjectConfigSchema = z
project: z .strictObject({
.string({ error: 'ktx.yaml field "project" is required' }) project: z
.trim() .string({ error: 'ktx.yaml field "project" is required' })
.min(1, 'ktx.yaml field "project" is required'), .trim()
setup: setupSchema.optional(), .min(1, 'ktx.yaml field "project" is required')
connections: z.record(z.string(), connectionSchema).default({}), .describe('Project identifier; used in logs, ktx state files, and as the default workspace name.'),
storage: storageSchema.prefault({}), setup: setupSchema.optional().describe('Setup-wizard state. Written by `ktx setup`; may be omitted.'),
llm: llmSchema.prefault({}), connections: z
ingest: ingestSchema.prefault({}), .record(z.string(), connectionSchema)
agent: agentSchema.prefault({}), .default({})
memory: memorySchema.prefault({}), .describe('Map of connection ID to connector configuration. Keys are user-chosen names referenced elsewhere in the config.'),
scan: scanSchema.prefault({}), storage: storageSchema.prefault({}).describe('Storage backends and commit policy for KTX state and search indexes.'),
}); llm: llmSchema.prefault({}).describe('LLM provider, per-role model overrides, and prompt-caching tunables.'),
ingest: ingestSchema.prefault({}).describe('Ingest pipeline configuration.'),
agent: agentSchema.prefault({}).describe('Agent feature configuration.'),
memory: memorySchema.prefault({}).describe('Memory subsystem configuration.'),
scan: scanSchema.prefault({}).describe('Schema-scan configuration: enrichment and relationship discovery.'),
})
.describe('Configuration schema for KTX project files (ktx.yaml).');
export type KtxProjectConfig = z.infer<typeof ktxProjectConfigSchema>; export type KtxProjectConfig = z.infer<typeof ktxProjectConfigSchema>;
export type KtxProjectLlmConfig = z.infer<typeof llmSchema>; export type KtxProjectLlmConfig = z.infer<typeof llmSchema>;
@ -260,6 +370,19 @@ export function validateKtxProjectConfig(raw: string): KtxConfigValidation {
return { ok: false, issues: collectIssues(result.error, parsed) }; return { ok: false, issues: collectIssues(result.error, parsed) };
} }
export function generateKtxProjectConfigJsonSchema(): Record<string, unknown> {
const schema = z.toJSONSchema(ktxProjectConfigSchema, {
target: 'draft-7',
io: 'input',
}) as Record<string, unknown>;
return {
$schema: 'http://json-schema.org/draft-07/schema#',
$id: 'https://ktx.dev/schemas/ktx-project-config.json',
title: 'ktx.yaml',
...schema,
};
}
export function serializeKtxProjectConfig(config: KtxProjectConfig): string { export function serializeKtxProjectConfig(config: KtxProjectConfig): string {
const serializedConfig = const serializedConfig =
config.ingest.adapters.length === 0 config.ingest.adapters.length === 0

View file

@ -10,6 +10,7 @@ export type {
} from './config.js'; } from './config.js';
export { export {
buildDefaultKtxProjectConfig, buildDefaultKtxProjectConfig,
generateKtxProjectConfigJsonSchema,
parseKtxProjectConfig, parseKtxProjectConfig,
serializeKtxProjectConfig, serializeKtxProjectConfig,
validateKtxProjectConfig, validateKtxProjectConfig,