Merge remote-tracking branch 'origin/main' into explore-research-agent-tools

# Conflicts:
#	packages/cli/src/print-command-tree.test.ts
#	packages/context/skills/sl_capture/SKILL.md
This commit is contained in:
Andrey Avtomonov 2026-05-14 22:05:00 +02:00
commit 6c73029d0c
163 changed files with 2908 additions and 1663 deletions

View file

@ -12,7 +12,7 @@ refs:
## Customer Update Communication Standard
**Source:** Notion People & Operating Norms, last edited 2026-05-07
**Source:** Notion - People & Operating Norms, last edited 2026-05-07
---

View file

@ -12,23 +12,23 @@ refs:
## New Hire Week-One Onboarding Policy
**Source:** Notion People & Operating Norms, last edited 2026-05-07
**Source:** Notion - People & Operating Norms, last edited 2026-05-07
**Owner:** Manager (not People Ops)
---
## Policy
Every new hire must understand **four things by end of week one**. The manager — not People Ops — is responsible for supplying this context.
Every new hire must understand **four things by end of week one**. The manager - not People Ops - is responsible for supplying this context.
## Required Week-One Knowledge
| # | What the new hire must understand |
|---|---|
| 1 | **What Orbit sells** the core procurement workflow product and value proposition |
| 2 | **Why procurement workflow gets messy inside a customer** the pain points that make Orbit necessary |
| 3 | **Which team handles which part of the customer lifecycle** team lanes and ownership boundaries |
| 4 | **What their first useful project is** a concrete, scoped piece of work they can contribute to immediately |
| 1 | **What Orbit sells** - the core procurement workflow product and value proposition |
| 2 | **Why procurement workflow gets messy inside a customer** - the pain points that make Orbit necessary |
| 3 | **Which team handles which part of the customer lifecycle** - team lanes and ownership boundaries |
| 4 | **What their first useful project is** - a concrete, scoped piece of work they can contribute to immediately |
## Ownership

View file

@ -21,7 +21,7 @@ tables:
# Activation KPI Glossary
**Owner team:** Growth
**Source:** Notion Orbit Demo Home / Data Team - Onboarding / Activation KPI Glossary, last edited 2026-05-07
**Source:** Notion - Orbit Demo Home / Data Team - Onboarding / Activation KPI Glossary, last edited 2026-05-07
Use this when a question is about signup-to-habit behavior. Orbit uses activation language across Growth, Product, and CS conversations.
@ -41,7 +41,7 @@ A customer is **activated** when **all three** of the following happen **within
| 2. Email Verified | `customer.email_verified_at` is not null | `orbit_analytics.customer` |
| 3. First Project | At least one row in `orbit_analytics.project` for the customer | `orbit_analytics.project` |
| 4. Team Invite | At least one row in `orbit_analytics.invite` for the customer | `orbit_analytics.invite` |
| 5. Activated | All of (2), (3), and (4) within 14 days of (1) | |
| 5. Activated | All of (2), (3), and (4) within 14 days of (1) | - |
## Conversion-Rate KPIs
@ -51,7 +51,7 @@ A customer is **activated** when **all three** of the following happen **within
| **D14 Activation Rate** | `activated_customers_within_14_days / signups_in_cohort` |
| **Time-to-Activate** | `median(activated_at - created_at)` in hours |
Growth conversations typically use D7 and D14 Activation Rate. Product and CS may ask about individual funnel steps confirm whether they mean the full activation definition or only one stage.
Growth conversations typically use D7 and D14 Activation Rate. Product and CS may ask about individual funnel steps - confirm whether they mean the full activation definition or only one stage.
## Source Notes

View file

@ -12,7 +12,7 @@ sl_refs:
- mart_account_activity
---
# Activation Policy Change January 2026
# Activation Policy Change - January 2026
**Governed metric key:** `activated_accounts`
**Owner team:** growth
@ -23,8 +23,8 @@ sl_refs:
The activation workflow changed on **2026-01-15**. All activation events are tagged with `policy_version`:
- `pre_2026_01_15` events before the workflow update
- `post_2026_01_15` events after the workflow update
- `pre_2026_01_15` - events before the workflow update
- `post_2026_01_15` - events after the workflow update
## Activation Event Types

View file

@ -13,7 +13,7 @@ sl_refs:
- mart_account_segments
---
# ARR Contract-First Definition
# ARR - Contract-First Definition
**Governed metric key:** `arr`
**Owner team:** finance
@ -30,10 +30,10 @@ The dbt test on `mart_arr_daily.arr_cents` asserts the value equals **1,874,200,
## Intermediate model
`int_active_contract_arr` active contract ARR as of 2026-03-31 (grain: `contract_id`).
`int_active_contract_arr` - active contract ARR as of 2026-03-31 (grain: `contract_id`).
## Related
- `stg_contracts` contract records (status: draft, active, cancelled, expired)
- `stg_subscriptions` fallback ARR source (status: active, cancelled, past_due, trialing)
- `mart_arr_daily` board-prep daily ARR mart
- `stg_contracts` - contract records (status: draft, active, cancelled, expired)
- `stg_subscriptions` - fallback ARR source (status: active, cancelled, past_due, trialing)
- `mart_arr_daily` - board-prep daily ARR mart

View file

@ -15,14 +15,14 @@ refs:
# Orbit Company Overview
**Source:** Notion Orbit Demo Home / Company Overview + Orbit Demo Home (root), last edited 2026-05-07
**Source:** Notion - Orbit Demo Home / Company Overview + Orbit Demo Home (root), last edited 2026-05-07
## What Orbit Sells
Orbit sells procurement workflow and spend-control software. The core value proposition: route purchase requests, collect approvals, onboard suppliers, and issue purchase orders without turning every exception into a status hunt.
**Primary buyers:** Finance, Procurement, Business Operations.
**Daily users:** department admins, office managers, IT leads, legal ops partners anyone who has to get a vendor through the building.
**Daily users:** department admins, office managers, IT leads, legal ops partners - anyone who has to get a vendor through the building.
## Product Workflow

View file

@ -21,18 +21,18 @@ sl_refs:
## Risk Levels
`low`, `medium`, `high` derived from two signal types:
`low`, `medium`, `high` - derived from two signal types:
1. **Support ticket signals** (`stg_support_tickets`): open or pending tickets with severity `high` or `critical` increase risk.
2. **Procurement activity signals** (`stg_purchase_requests`, `stg_purchase_orders`): recent qualifying procurement actions reduce risk.
## Intermediate Model
`int_customer_health_signals` combines open critical ticket count and recent procurement action count per account.
`int_customer_health_signals` - combines open critical ticket count and recent procurement action count per account.
## Mart
`mart_customer_health` account-grain risk mart as of **2026-03-31**.
`mart_customer_health` - account-grain risk mart as of **2026-03-31**.
- `account_id`: dbt not_null, unique
- `risk_level`: dbt accepted_values [low, medium, high]

View file

@ -13,7 +13,7 @@ refs:
## Customer Stakeholder Needs by Role
**Source:** Notion Product & Customers, last edited 2026-05-07
**Source:** Notion - Product & Customers, last edited 2026-05-07
---
@ -26,7 +26,7 @@ These are recurring, role-specific customer needs observed across accounts. Use
| Role | Primary Need | Implication |
|---|---|---|
| **Finance** | Committed spend visibility earlier in the procurement cycle | Surface budget commitments at request approval, not at PO creation |
| **Department leaders** | Request speed faster time from request to approval | Reduce approval routing friction; minimize back-and-forth |
| **Department leaders** | Request speed - faster time from request to approval | Reduce approval routing friction; minimize back-and-forth |
| **Procurement** | Supplier file complete before the first invoice | Supplier onboarding must be finished before PO is issued, not after |
| **Legal** | Fewer emergency reviews | Route contracts with legal implications earlier; avoid last-minute escalations |
| **Customer Success (internal)** | Renewal risk visible before the account is already annoyed | CS needs leading indicators of dissatisfaction, not lagging ones |

View file

@ -20,7 +20,7 @@ tables:
**Table:** `orbit_analytics.customer`
**Grain:** one row per signed-up customer
**Source:** Notion Orbit Demo Home / Data Team - Onboarding / Orbit Customers Source, last edited 2026-05-07
**Source:** Notion - Orbit Demo Home / Data Team - Onboarding / Orbit Customers Source, last edited 2026-05-07
Use this when a question needs customer identity, plan tier, signup timing, recent activity, or the standard customer joins.
@ -29,7 +29,7 @@ Use this when a question needs customer identity, plan tier, signup timing, rece
| Column | Type | Notes |
|---|---|---|
| `id` | number | Primary key, surrogate key |
| `email` | string | Login email, unique **do not use as join key** |
| `email` | string | Login email, unique - **do not use as join key** |
| `name` | string | Display name |
| `country` | string | ISO 3166-1 alpha-2 code |
| `plan_tier` | string | One of `free`, `pro`, `enterprise` |

View file

@ -12,7 +12,7 @@ refs:
## How We Work
**Source:** Notion Orbit Demo Home / How We Work, last edited 2026-05-07
**Source:** Notion - Orbit Demo Home / How We Work, last edited 2026-05-07
---
@ -30,7 +30,7 @@ refs:
|---|---|
| **Monday** | Commitments and dependency checks |
| **Tuesday Thursday** | Customer calls, product work, implementation, and building |
| **Friday** | Closing loops review what shipped, what slipped, and write down any decisions |
| **Friday** | Closing loops - review what shipped, what slipped, and write down any decisions |
Use this rhythm when scheduling work, meetings, or reviews. Do not schedule decision-making meetings on Fridays; use Friday to record decisions already made.
@ -64,11 +64,11 @@ These are explicitly codified rules Orbit has identified as recurring failure mo
- **Escalations are coordination tools, not indicators of individual failure.** Escalating is the correct behavior when a problem exceeds the current team's ability to resolve it alone.
- When escalating, the person escalating must:
1. Bring in the right people (those with authority or context to unblock).
2. Summarize current state clearly what has been tried, what is blocked, and why.
2. Summarize current state clearly - what has been tried, what is blocked, and why.
3. Name the customer impact explicitly.
4. Keep updates moving until the risk is resolved or a workaround is established.
- Escalations that stall because no one owns the next update are a process failure, not a customer failure.
- An escalation is closed when the risk is resolved or a documented workaround is in place not when the immediate noise stops.
- An escalation is closed when the risk is resolved or a documented workaround is in place - not when the immediate noise stops.
---

View file

@ -14,7 +14,7 @@ refs:
## Known Product Gaps and Friction Points
**Source:** Notion Product & Customers (Notes from Recent Customer Calls), last edited 2026-05-07
**Source:** Notion - Product & Customers (Notes from Recent Customer Calls), last edited 2026-05-07
---

View file

@ -33,8 +33,8 @@ tables:
## Key measures (SL: `mart_account_activity`)
- `avg_pre_policy_activation_rate` `avg(pre_policy_30_day_activation_rate)`
- `avg_post_policy_activation_rate` `avg(post_policy_30_day_activation_rate)`
- `avg_pre_policy_activation_rate` - `avg(pre_policy_30_day_activation_rate)`
- `avg_post_policy_activation_rate` - `avg(post_policy_30_day_activation_rate)`
## Common query patterns

View file

@ -37,10 +37,10 @@ tables:
## Key measures (SL: `mart_account_segments`)
- `account_count` `count(*)`
- `total_contract_arr_cents` `sum(contract_arr_cents)`
- `active_contract_arr_cents` `sum(contract_arr_cents)` where `contract_status = 'active'`
- `active_contract_arr_millions` active ARR in $M
- `account_count` - `count(*)`
- `total_contract_arr_cents` - `sum(contract_arr_cents)`
- `active_contract_arr_cents` - `sum(contract_arr_cents)` where `contract_status = 'active'`
- `active_contract_arr_millions` - active ARR in $M
## Common query patterns

View file

@ -31,8 +31,8 @@ tables:
## Key measures (SL: `mart_arr_daily`)
- `total_arr_cents` `sum(arr_cents)`
- `arr_millions` `round(sum(arr_cents) / 100000000.0, 3)` ARR in $M
- `total_arr_cents` - `sum(arr_cents)`
- `arr_millions` - `round(sum(arr_cents) / 100000000.0, 3)` - ARR in $M
## Common query patterns

View file

@ -38,8 +38,8 @@ tables:
## Key measures (SL: `mart_nrr_quarterly`)
- `avg_nrr` `avg(net_revenue_retention)` across all rows
- `avg_nrr_enterprise` `avg(net_revenue_retention)` filtered to `segment = 'enterprise'`
- `avg_nrr` - `avg(net_revenue_retention)` across all rows
- `avg_nrr_enterprise` - `avg(net_revenue_retention)` filtered to `segment = 'enterprise'`
- `total_expansion_arr_cents`, `total_contraction_arr_cents`, `total_churned_arr_cents`
## Common query patterns

View file

@ -32,8 +32,8 @@ tables:
## Key measures (SL: `mart_procurement_activity`)
- `total_active_requesters` `sum(active_requesters)`
- `active_requesters_200k_threshold` `sum(active_requesters)` where `contract_arr_threshold_cents = 20000000`
- `total_active_requesters` - `sum(active_requesters)`
- `active_requesters_200k_threshold` - `sum(active_requesters)` where `contract_arr_threshold_cents = 20000000`
## Common query patterns
@ -44,4 +44,4 @@ tables:
- `active_requesters` counts non-internal, non-test requesters on large active contracts. See [orbit-procurement-qualifying-actions](orbit-procurement-qualifying-actions).
- The standard threshold is `contract_arr_threshold_cents = 20000000` ($200k ARR).
- Always filter by `contract_arr_threshold_cents` the table contains rows for multiple threshold values.
- Always filter by `contract_arr_threshold_cents` - the table contains rows for multiple threshold values.

View file

@ -36,12 +36,12 @@ tables:
## Key measures (SL: `mart_revenue_daily`)
- `total_gross_revenue_cents` `sum(gross_revenue_cents)`
- `total_credits_cents` `sum(credits_cents)`
- `total_refunds_cents` `sum(refunds_cents)`
- `total_net_revenue_cents` `sum(net_revenue_cents)`
- `net_revenue_millions` `round(sum(net_revenue_cents) / 100000000.0, 3)`
- `gross_revenue_millions` `round(sum(gross_revenue_cents) / 100000000.0, 3)`
- `total_gross_revenue_cents` - `sum(gross_revenue_cents)`
- `total_credits_cents` - `sum(credits_cents)`
- `total_refunds_cents` - `sum(refunds_cents)`
- `total_net_revenue_cents` - `sum(net_revenue_cents)`
- `net_revenue_millions` - `round(sum(net_revenue_cents) / 100000000.0, 3)`
- `gross_revenue_millions` - `round(sum(gross_revenue_cents) / 100000000.0, 3)`
## Common query patterns

View file

@ -15,7 +15,7 @@ sl_refs:
- mart_nrr_quarterly
---
# Orbit Metabase SQL Library Patterns & Conventions
# Orbit Metabase SQL Library - Patterns & Conventions
Collection **7 "SQL Library"** (parent: Orbit Showcase, collection 5) contains reference queries that demonstrate how to write Metabase native SQL against the Orbit analytics marts. Cards here are intentionally illustrative; several have `dashboardCount: 0` and are not embedded in live dashboards.

View file

@ -13,7 +13,7 @@ sl_refs:
- mart_nrr_quarterly
---
# NRR Discount Expiration Treatment
# NRR - Discount Expiration Treatment
**Governed metric key:** `net_revenue_retention`
**Owner team:** analytics

View file

@ -12,7 +12,7 @@ sl_refs:
- mart_procurement_activity
---
# Procurement Qualifying Actions & Weekly Active Requesters
# Procurement - Qualifying Actions & Weekly Active Requesters
**Governed metric key:** `weekly_active_requesters`
**Owner team:** product

View file

@ -13,7 +13,7 @@ refs:
## Orbit Product Design Principles
**Source:** Notion Product & Customers, last edited 2026-05-07
**Source:** Notion - Product & Customers, last edited 2026-05-07
---

View file

@ -13,7 +13,7 @@ refs:
## Product Review Checklist
**Source:** Notion Product & Customers, last edited 2026-05-07
**Source:** Notion - Product & Customers, last edited 2026-05-07
---

View file

@ -12,7 +12,7 @@ sl_refs:
- mart_revenue_daily
---
# Revenue Gross-to-Net Reconciliation
# Revenue - Gross-to-Net Reconciliation
**Governed metric key:** `net_revenue`
**Owner team:** finance
@ -25,7 +25,7 @@ sl_refs:
net_revenue = gross_revenue - credits - refunds
```
All amounts are in **cents** (USD only `stg_invoices.currency` is asserted to be `USD`).
All amounts are in **cents** (USD only - `stg_invoices.currency` is asserted to be `USD`).
## Components
@ -38,12 +38,12 @@ All amounts are in **cents** (USD only — `stg_invoices.currency` is asserted t
## Intermediate model
`int_revenue_components` daily gross, credit, refund, and net revenue components.
`int_revenue_components` - daily gross, credit, refund, and net revenue components.
## Quality Gates
- `reconciliation_check` must be `true` on every row of `mart_revenue_daily`.
- `assert_february_2026_net_revenue` a dbt singular test covering February 2026 net revenue total.
- `assert_february_2026_net_revenue` - a dbt singular test covering February 2026 net revenue total.
## Line Item Types (`stg_invoice_line_items`)

View file

@ -14,7 +14,7 @@ refs:
## Sales Ops → Customer Success Implementation Handoff
**Source:** Notion People & Operating Norms, last edited 2026-05-07
**Source:** Notion - People & Operating Norms, last edited 2026-05-07
**Owner:** Sales Ops (sender), Customer Success (receiver)
---
@ -27,7 +27,7 @@ Sales Ops must complete the handoff **before the first implementation call**. Cu
| Field | Notes |
|---|---|
| Current plan | Starter / Growth / Enterprise use canonical plan name |
| Current plan | Starter / Growth / Enterprise - use canonical plan name |
| Account segment | self_serve / commercial / enterprise (see `orbit-plan-segment-normalization`) |
| Contract shape | Term, ARR, any discounts or custom terms |
| Renewal contact | Named person on the customer side responsible for renewal |
@ -38,7 +38,7 @@ Sales Ops must complete the handoff **before the first implementation call**. Cu
- **Sales Ops** is responsible for populating and delivering the handoff before the first implementation call.
- **Customer Success** is responsible for flagging missing fields to Sales Ops before the call, not during or after.
- If a field is unknown at handoff time, Sales Ops must note it explicitly as "unknown to be resolved by [date]" rather than leaving it blank.
- If a field is unknown at handoff time, Sales Ops must note it explicitly as "unknown - to be resolved by [date]" rather than leaving it blank.
## Common Failure Mode
@ -51,7 +51,7 @@ Handoffs that omit contract shape or renewal contact force CS to re-engage Sales
- Enterprise accounts with parent/child account structures require extra care during handoff.
- Small assumptions made during handoff in these accounts tend to produce large downstream problems (billing mismatches, approval routing failures, supplier onboarding gaps).
- When the account has parent/child complexity, Sales Ops must explicitly flag it in the handoff and document the account hierarchy before the first implementation call.
- CS should treat any undocumented parent/child relationship as a blocker do not proceed with implementation setup until the structure is confirmed.
- CS should treat any undocumented parent/child relationship as a blocker - do not proceed with implementation setup until the structure is confirmed.
---

View file

@ -21,7 +21,7 @@ export interface KtxCliCommandContext {
deps: KtxCliDeps;
packageInfo: KtxCliPackageInfo;
setExitCode: (code: number) => void;
runInit: (args: { projectDir: string; projectName?: string; force: boolean }, io: KtxCliIo) => Promise<number>;
runInit: (args: { projectDir: string; force: boolean }, io: KtxCliIo) => Promise<number>;
writeDebug?: (command: string, commandContext: CommandWithGlobalOptions) => void;
}
@ -33,14 +33,14 @@ export interface OutputModeOptions {
}
interface KtxCommanderProgramOptions {
runInit: (args: { projectDir: string; projectName?: string; force: boolean }, io: KtxCliIo) => Promise<number>;
runInit: (args: { projectDir: string; force: boolean }, io: KtxCliIo) => Promise<number>;
}
export interface BuildKtxProgramOptions {
io: KtxCliIo;
deps: KtxCliDeps;
packageInfo: KtxCliPackageInfo;
runInit: (args: { projectDir: string; projectName?: string; force: boolean }, io: KtxCliIo) => Promise<number>;
runInit: (args: { projectDir: string; force: boolean }, io: KtxCliIo) => Promise<number>;
setExitCode?: (code: number) => void;
}

View file

@ -65,14 +65,10 @@ export function packageInfoFromJson(packageJson: unknown): KtxCliPackageInfo {
};
}
async function runInit(
args: { projectDir: string; projectName?: string; force: boolean },
io: KtxCliIo,
): Promise<number> {
async function runInit(args: { projectDir: string; force: boolean }, io: KtxCliIo): Promise<number> {
const { initKtxProject } = await import('@ktx/context/project');
const result = await initKtxProject({
projectDir: args.projectDir,
projectName: args.projectName,
force: args.force,
});
@ -83,7 +79,7 @@ async function runInit(
}
export async function runInitForCommander(
args: { projectDir: string; projectName?: string; force: boolean },
args: { projectDir: string; force: boolean },
io: KtxCliIo,
): Promise<number> {
return await runInit(args, io);

View file

@ -33,12 +33,24 @@ export function registerConnectionCommands(program: Command, context: KtxCliComm
connection
.command('test')
.description('Test a configured connection')
.argument('<connectionId>', 'KTX connection id')
.action(async (connectionId: string, _options: unknown, command) => {
.argument('[connectionId]', 'KTX connection id (omit when --all is set)')
.option('--all', 'Test every configured connection and print a summary list')
.action(async (connectionId: string | undefined, options: { all?: boolean }, command) => {
const all = options.all === true;
if (all && connectionId !== undefined) {
command.error('error: --all cannot be combined with a connection id argument');
}
if (!all && connectionId === undefined) {
command.error('error: missing required argument <connectionId> (or pass --all)');
}
if (all) {
await runConnectionArgs(context, { command: 'test-all', projectDir: resolveCommandProjectDir(command) });
return;
}
await runConnectionArgs(context, {
command: 'test',
projectDir: resolveCommandProjectDir(command),
connectionId,
connectionId: connectionId as string,
});
});
}

View file

@ -17,16 +17,51 @@ export function registerStatusCommands(program: Command, context: KtxCliCommandC
.description('Check current KTX setup and project readiness')
.option('--json', 'Print JSON output', false)
.option('-v, --verbose', 'Show every check, including passing ones', false)
.option('--validate', 'Only validate the ktx.yaml schema; skip readiness checks', false)
.option('--no-input', 'Disable interactive terminal input')
.action(async (options: { json?: boolean; verbose?: boolean; input?: boolean }, command) => {
const runner = context.deps.doctor ?? (await import('../doctor.js')).runKtxDoctor;
const explicitOrEnvProjectDir = resolveCommandProjectDirOverride(command);
const nearestProjectDir = explicitOrEnvProjectDir ? undefined : findNearestKtxProjectDir(process.cwd());
if (!explicitOrEnvProjectDir && !nearestProjectDir) {
.action(
async (
options: { json?: boolean; verbose?: boolean; validate?: boolean; input?: boolean },
command,
) => {
const runner = context.deps.doctor ?? (await import('../doctor.js')).runKtxDoctor;
const explicitOrEnvProjectDir = resolveCommandProjectDirOverride(command);
const nearestProjectDir = explicitOrEnvProjectDir ? undefined : findNearestKtxProjectDir(process.cwd());
if (options.validate === true) {
context.setExitCode(
await runner(
{
command: 'validate',
projectDir: resolveCommandProjectDir(command),
outputMode: outputMode(options),
...inputMode(options),
},
context.io,
),
);
return;
}
if (!explicitOrEnvProjectDir && !nearestProjectDir) {
context.setExitCode(
await runner(
{
command: 'setup',
outputMode: outputMode(options),
verbose: options.verbose === true,
...inputMode(options),
},
context.io,
),
);
return;
}
context.setExitCode(
await runner(
{
command: 'setup',
command: 'project',
projectDir: resolveCommandProjectDir(command),
outputMode: outputMode(options),
verbose: options.verbose === true,
...inputMode(options),
@ -34,19 +69,6 @@ export function registerStatusCommands(program: Command, context: KtxCliCommandC
context.io,
),
);
return;
}
context.setExitCode(
await runner(
{
command: 'project',
projectDir: resolveCommandProjectDir(command),
outputMode: outputMode(options),
verbose: options.verbose === true,
...inputMode(options),
},
context.io,
),
);
});
},
);
}

View file

@ -1,12 +1,16 @@
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { MetabaseRuntimeClient } from '@ktx/context/ingest';
import type { LookerClient, MetabaseRuntimeClient, NotionClient } from '@ktx/context/ingest';
import { initKtxProject, parseKtxProjectConfig, serializeKtxProjectConfig } from '@ktx/context/project';
import type { KtxConnectionDriver, KtxScanConnector, KtxSchemaSnapshot } from '@ktx/context/scan';
import type { KtxConnectionDriver, KtxScanConnector } from '@ktx/context/scan';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { runKtxConnection } from './connection.js';
function stripAnsi(s: string): string {
return s.replace(/\[[0-9;]*m/g, '');
}
function makeIo() {
let stdout = '';
let stderr = '';
@ -28,28 +32,11 @@ function makeIo() {
};
}
function snapshotFor(driver: KtxConnectionDriver, tableNames: string[]): KtxSchemaSnapshot {
return {
connectionId: 'warehouse',
driver,
extractedAt: '2026-04-29T00:00:00.000Z',
scope: {},
metadata: {},
tables: tableNames.map((name) => ({
catalog: null,
db: null,
name,
kind: 'table',
comment: null,
estimatedRows: null,
columns: [],
foreignKeys: [],
})),
};
}
function nativeConnector(driver: KtxConnectionDriver, tableNames: string[]) {
const introspect = vi.fn(async () => snapshotFor(driver, tableNames));
function nativeConnector(
driver: KtxConnectionDriver,
testResult: { success: true } | { success: false; error: string } = { success: true },
) {
const testConnection = vi.fn(async () => testResult);
const cleanup = vi.fn(async () => undefined);
const connector: KtxScanConnector = {
id: `${driver}:warehouse`,
@ -65,10 +52,13 @@ function nativeConnector(driver: KtxConnectionDriver, tableNames: string[]) {
formalForeignKeys: false,
estimatedRowCounts: false,
},
introspect,
introspect: vi.fn(async () => {
throw new Error('introspect should not be called from connection test');
}),
testConnection,
cleanup,
};
return { connector, introspect, cleanup };
return { connector, testConnection, cleanup };
}
describe('runKtxConnection', () => {
@ -92,7 +82,7 @@ describe('runKtxConnection', () => {
it('lists configured connections without resolving secrets', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir, projectName: 'warehouse' });
await initKtxProject({ projectDir });
await writeConnections(projectDir, {
warehouse: { driver: 'postgres', url: 'env:DATABASE_URL' },
docs: { driver: 'notion', auth_token_ref: 'env:NOTION_TOKEN', crawl_mode: 'all_accessible' },
@ -110,7 +100,7 @@ describe('runKtxConnection', () => {
it('prints an empty-state message that points at setup instead of removed connection add', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir, projectName: 'warehouse' });
await initKtxProject({ projectDir });
const io = makeIo();
await expect(runKtxConnection({ command: 'list', projectDir }, io.io)).resolves.toBe(0);
@ -119,13 +109,13 @@ describe('runKtxConnection', () => {
expect(io.stdout()).not.toContain('ktx connection add');
});
it('tests a configured connection through the native scan connector', async () => {
it('tests a native connection by calling connector.testConnection (not introspect)', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir, projectName: 'warehouse' });
await initKtxProject({ projectDir });
await writeConnections(projectDir, {
warehouse: { driver: 'sqlite' },
});
const { connector, introspect, cleanup } = nativeConnector('sqlite', ['customers', 'orders']);
const { connector, testConnection, cleanup } = nativeConnector('sqlite');
const createScanConnector = vi.fn(async () => connector);
const io = makeIo();
@ -136,25 +126,36 @@ describe('runKtxConnection', () => {
).resolves.toBe(0);
expect(createScanConnector).toHaveBeenCalledWith(expect.objectContaining({ projectDir }), 'warehouse');
expect(introspect).toHaveBeenCalledWith(
{
connectionId: 'warehouse',
driver: 'sqlite',
mode: 'structural',
dryRun: true,
detectRelationships: false,
},
{ runId: 'connection-test-warehouse' },
);
expect(testConnection).toHaveBeenCalledTimes(1);
expect(connector.introspect).not.toHaveBeenCalled();
expect(cleanup).toHaveBeenCalledTimes(1);
expect(io.stdout()).toContain('Connection test passed: warehouse');
expect(io.stdout()).toContain('Driver: sqlite');
expect(io.stdout()).toContain('Tables: 2');
expect(io.stdout()).toContain('Status: ok');
});
it('reports the connector error and still cleans up when native testConnection fails', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir });
await writeConnections(projectDir, {
warehouse: { driver: 'sqlite' },
});
const { connector, cleanup } = nativeConnector('sqlite', { success: false, error: 'database file is unreadable' });
const io = makeIo();
await expect(
runKtxConnection({ command: 'test', projectDir, connectionId: 'warehouse' }, io.io, {
createScanConnector: vi.fn(async () => connector),
}),
).resolves.toBe(1);
expect(cleanup).toHaveBeenCalledTimes(1);
expect(io.stderr()).toContain('database file is unreadable');
});
it('tests a configured Metabase connection through the Metabase runtime client', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir, projectName: 'warehouse' });
await initKtxProject({ projectDir });
await writeConnections(projectDir, {
prod_metabase: {
driver: 'metabase',
@ -198,41 +199,305 @@ describe('runKtxConnection', () => {
expect(io.stderr()).toBe('');
});
it('cleans up the native scan connector when connection testing fails', async () => {
it('tests a Looker connection through the Looker client', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir, projectName: 'warehouse' });
await initKtxProject({ projectDir });
await writeConnections(projectDir, {
warehouse: { driver: 'sqlite' },
});
const cleanup = vi.fn(async () => undefined);
const connector: KtxScanConnector = {
id: 'sqlite:warehouse',
driver: 'sqlite',
capabilities: {
structuralIntrospection: true,
tableSampling: false,
columnSampling: false,
columnStats: false,
readOnlySql: false,
nestedAnalysis: false,
eventStreamDiscovery: false,
formalForeignKeys: false,
estimatedRowCounts: false,
bi_looker: {
driver: 'looker',
base_url: 'https://looker.example.test',
client_id: 'cid',
client_secret: 'csecret', // pragma: allowlist secret
},
introspect: vi.fn(async () => {
throw new Error('database file is unreadable');
}),
cleanup,
};
});
const testConnection = vi.fn(async () => ({
success: true as const,
metadata: { displayName: 'Alice Analyst', userId: '42' },
}));
const createLookerClient = vi.fn(async (): Promise<Pick<LookerClient, 'testConnection'>> => ({ testConnection }));
const io = makeIo();
await expect(
runKtxConnection({ command: 'test', projectDir, connectionId: 'warehouse' }, io.io, {
createScanConnector: vi.fn(async () => connector),
}),
runKtxConnection({ command: 'test', projectDir, connectionId: 'bi_looker' }, io.io, { createLookerClient }),
).resolves.toBe(0);
expect(createLookerClient).toHaveBeenCalledWith(expect.objectContaining({ projectDir }), 'bi_looker');
expect(testConnection).toHaveBeenCalledTimes(1);
expect(io.stdout()).toContain('Connection test passed: bi_looker');
expect(io.stdout()).toContain('Driver: looker');
expect(io.stdout()).toContain('User: Alice Analyst');
});
it('falls back to userId when Looker metadata has no display name', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir });
await writeConnections(projectDir, {
bi_looker: {
driver: 'looker',
base_url: 'https://looker.example.test',
client_id: 'cid',
client_secret: 'csecret', // pragma: allowlist secret
},
});
const createLookerClient = vi.fn(async (): Promise<Pick<LookerClient, 'testConnection'>> => ({
testConnection: vi.fn(async () => ({
success: true as const,
metadata: { displayName: null, userId: '42' },
})),
}));
const io = makeIo();
await expect(
runKtxConnection({ command: 'test', projectDir, connectionId: 'bi_looker' }, io.io, { createLookerClient }),
).resolves.toBe(0);
expect(io.stdout()).toContain('User: 42');
});
it('reports the Looker error when testConnection fails', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir });
await writeConnections(projectDir, {
bi_looker: {
driver: 'looker',
base_url: 'https://looker.example.test',
client_id: 'cid',
client_secret: 'csecret', // pragma: allowlist secret
},
});
const createLookerClient = vi.fn(async (): Promise<Pick<LookerClient, 'testConnection'>> => ({
testConnection: vi.fn(async () => ({ success: false as const, error: 'invalid client_id' })),
}));
const io = makeIo();
await expect(
runKtxConnection({ command: 'test', projectDir, connectionId: 'bi_looker' }, io.io, { createLookerClient }),
).resolves.toBe(1);
expect(io.stderr()).toContain('Looker connection test failed: invalid client_id');
});
it('tests a Notion connection by retrieving the bot user', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir });
await writeConnections(projectDir, {
docs: {
driver: 'notion',
auth_token: 'secret_token', // pragma: allowlist secret
crawl_mode: 'all_accessible',
},
});
const retrieveBotUser = vi.fn(async () => ({ id: 'bot-1', name: 'Analytics Bot' }));
const createNotionClient = vi.fn(async (): Promise<Pick<NotionClient, 'retrieveBotUser'>> => ({ retrieveBotUser }));
const io = makeIo();
await expect(
runKtxConnection({ command: 'test', projectDir, connectionId: 'docs' }, io.io, { createNotionClient }),
).resolves.toBe(0);
expect(createNotionClient).toHaveBeenCalledWith(expect.objectContaining({ projectDir }), 'docs');
expect(retrieveBotUser).toHaveBeenCalledTimes(1);
expect(io.stdout()).toContain('Connection test passed: docs');
expect(io.stdout()).toContain('Driver: notion');
expect(io.stdout()).toContain('Bot: Analytics Bot');
});
it('falls back to bot id when Notion bot has no name', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir });
await writeConnections(projectDir, {
docs: {
driver: 'notion',
auth_token: 'secret_token', // pragma: allowlist secret
crawl_mode: 'all_accessible',
},
});
const createNotionClient = vi.fn(async (): Promise<Pick<NotionClient, 'retrieveBotUser'>> => ({
retrieveBotUser: vi.fn(async () => ({ id: 'bot-1', name: null })),
}));
const io = makeIo();
await expect(
runKtxConnection({ command: 'test', projectDir, connectionId: 'docs' }, io.io, { createNotionClient }),
).resolves.toBe(0);
expect(io.stdout()).toContain('Bot: bot-1');
});
it('tests a dbt connection via testRepoConnection (success)', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir });
process.env.DBT_TOKEN = 'gh_token_abc'; // pragma: allowlist secret
await writeConnections(projectDir, {
'dbt-main': {
driver: 'dbt',
repo_url: 'https://github.com/example/dbt-project',
auth_token_ref: 'env:DBT_TOKEN',
},
});
const testRepoConnection = vi.fn(async () => ({ ok: true as const }));
const io = makeIo();
try {
await expect(
runKtxConnection({ command: 'test', projectDir, connectionId: 'dbt-main' }, io.io, { testRepoConnection }),
).resolves.toBe(0);
expect(testRepoConnection).toHaveBeenCalledWith({
repoUrl: 'https://github.com/example/dbt-project',
authToken: 'gh_token_abc',
});
expect(io.stdout()).toContain('Connection test passed: dbt-main');
expect(io.stdout()).toContain('Driver: dbt');
expect(io.stdout()).toContain('Repo: https://github.com/example/dbt-project');
} finally {
delete process.env.DBT_TOKEN;
}
});
it('reports the git error when testRepoConnection fails for dbt', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir });
await writeConnections(projectDir, {
'dbt-main': {
driver: 'dbt',
repo_url: 'https://github.com/example/dbt-project',
},
});
const testRepoConnection = vi.fn(async () => ({ ok: false as const, error: 'fatal: auth failed' }));
const io = makeIo();
await expect(
runKtxConnection({ command: 'test', projectDir, connectionId: 'dbt-main' }, io.io, { testRepoConnection }),
).resolves.toBe(1);
expect(cleanup).toHaveBeenCalledTimes(1);
expect(io.stderr()).toContain('database file is unreadable');
expect(testRepoConnection).toHaveBeenCalledWith({
repoUrl: 'https://github.com/example/dbt-project',
authToken: null,
});
expect(io.stderr()).toContain('dbt repository check failed: fatal: auth failed');
});
it('tests a LookML connection via testRepoConnection with camelCase repoUrl', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir });
await writeConnections(projectDir, {
lookml_main: {
driver: 'lookml',
repoUrl: 'https://github.com/example/lookml',
},
});
const testRepoConnection = vi.fn(async () => ({ ok: true as const }));
const io = makeIo();
await expect(
runKtxConnection({ command: 'test', projectDir, connectionId: 'lookml_main' }, io.io, { testRepoConnection }),
).resolves.toBe(0);
expect(testRepoConnection).toHaveBeenCalledWith({
repoUrl: 'https://github.com/example/lookml',
authToken: null,
});
expect(io.stdout()).toContain('Driver: lookml');
expect(io.stdout()).toContain('Repo: https://github.com/example/lookml');
});
it('tests a MetricFlow connection via the nested metricflow block', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir });
await writeConnections(projectDir, {
mf_main: {
driver: 'metricflow',
metricflow: { repoUrl: 'https://github.com/example/metricflow' },
},
});
const testRepoConnection = vi.fn(async () => ({ ok: true as const }));
const io = makeIo();
await expect(
runKtxConnection({ command: 'test', projectDir, connectionId: 'mf_main' }, io.io, { testRepoConnection }),
).resolves.toBe(0);
expect(testRepoConnection).toHaveBeenCalledWith({
repoUrl: 'https://github.com/example/metricflow',
authToken: null,
});
expect(io.stdout()).toContain('Driver: metricflow');
});
it('--all: prints a single coherent list with one row per connection', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir });
await writeConnections(projectDir, {
warehouse: { driver: 'sqlite' },
docs: { driver: 'notion', auth_token: 'secret_token', crawl_mode: 'all_accessible' }, // pragma: allowlist secret
});
const { connector } = nativeConnector('sqlite');
const createScanConnector = vi.fn(async () => connector);
const createNotionClient = vi.fn(async (): Promise<Pick<NotionClient, 'retrieveBotUser'>> => ({
retrieveBotUser: vi.fn(async () => ({ id: 'bot-1', name: 'Docs Bot' })),
}));
const io = makeIo();
await expect(
runKtxConnection({ command: 'test-all', projectDir }, io.io, { createScanConnector, createNotionClient }),
).resolves.toBe(0);
const out = stripAnsi(io.stdout());
expect(out).toContain('connection test --all');
expect(out).toMatch(/docs\s+notion\s+✓ ok\s+Bot: Docs Bot/);
expect(out).toMatch(/warehouse\s+sqlite\s+✓ ok\s+Status: ok/);
expect(out).toContain('2 tested');
expect(out).toContain('2 passed');
expect(out).not.toContain('failed');
expect(io.stderr()).toBe('');
});
it('--all: marks failing connections, keeps passing ones, and returns non-zero', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir });
await writeConnections(projectDir, {
warehouse: { driver: 'sqlite' },
broken: { driver: 'sqlite' },
});
const okConnector = nativeConnector('sqlite').connector;
const failConnector = nativeConnector('sqlite', { success: false, error: 'database file is unreadable' }).connector;
const createScanConnector = vi.fn(async (_p, connectionId: string) =>
connectionId === 'broken' ? failConnector : okConnector,
);
const io = makeIo();
await expect(
runKtxConnection({ command: 'test-all', projectDir }, io.io, { createScanConnector }),
).resolves.toBe(1);
const out = stripAnsi(io.stdout());
expect(out).toMatch(/broken\s+sqlite\s+✗ failed\s+database file is unreadable/);
expect(out).toMatch(/warehouse\s+sqlite\s+✓ ok\s+Status: ok/);
expect(out).toContain('1 passed');
expect(out).toContain('1 failed');
expect(io.stderr()).toBe('');
});
it('--all: shows an empty-state message when no connections are configured', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir });
const io = makeIo();
await expect(runKtxConnection({ command: 'test-all', projectDir }, io.io)).resolves.toBe(0);
const out = stripAnsi(io.stdout());
expect(out).toContain('connection test --all');
expect(out).toContain('No connections configured. Run `ktx setup` to add one.');
});
it('rejects unknown drivers with a helpful error', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir });
await writeConnections(projectDir, {
mystery: { driver: 'duckdb' },
});
const io = makeIo();
await expect(
runKtxConnection({ command: 'test', projectDir, connectionId: 'mystery' }, io.io),
).resolves.toBe(1);
expect(io.stderr()).toContain('uses driver "duckdb"');
expect(io.stderr()).toContain('Supported:');
});
});

View file

@ -1,12 +1,21 @@
import {
DEFAULT_METABASE_CLIENT_CONFIG,
DefaultLookerConnectionClientFactory,
DefaultMetabaseConnectionClientFactory,
type LookerClient,
type MetabaseRuntimeClient,
type NotionBotInfo,
NotionClient,
createLocalLookerCredentialResolver,
metabaseRuntimeConfigFromLocalConnection,
testRepoConnection,
} from '@ktx/context/ingest';
import { parseNotionConnectionConfig, resolveNotionConnectionAuthToken } from '@ktx/context/connections';
import { resolveKtxConfigReference } from '@ktx/context/core';
import { type KtxLocalProject, loadKtxProject } from '@ktx/context/project';
import type { KtxScanConnector } from '@ktx/context/scan';
import type { KtxCliIo } from './index.js';
import { bold, dim, green, red, SYMBOLS } from './io/symbols.js';
import { createKtxCliScanConnector } from './local-scan-connectors.js';
import { profileMark } from './startup-profile.js';
@ -14,18 +23,37 @@ profileMark('module:connection');
export type KtxConnectionArgs =
| { command: 'list'; projectDir: string }
| { command: 'test'; projectDir: string; connectionId: string };
| { command: 'test'; projectDir: string; connectionId: string }
| { command: 'test-all'; projectDir: string };
interface KtxConnectionDeps {
type MetabaseTestPort = Pick<MetabaseRuntimeClient, 'testConnection' | 'getDatabases' | 'cleanup'>;
type LookerTestPort = Pick<LookerClient, 'testConnection'>;
type NotionTestPort = Pick<NotionClient, 'retrieveBotUser'>;
type TestRepoConnection = typeof testRepoConnection;
export interface KtxConnectionDeps {
createScanConnector?: typeof createKtxCliScanConnector;
createMetabaseClient?: typeof createDefaultMetabaseClient;
createMetabaseClient?: (project: KtxLocalProject, connectionId: string) => Promise<MetabaseTestPort>;
createLookerClient?: (project: KtxLocalProject, connectionId: string) => Promise<LookerTestPort>;
createNotionClient?: (project: KtxLocalProject, connectionId: string) => Promise<NotionTestPort>;
testRepoConnection?: TestRepoConnection;
}
async function cleanupConnector(connector: KtxScanConnector | null): Promise<void> {
if (connector?.cleanup) {
await connector.cleanup();
}
}
const SUPPORTED_TEST_DRIVERS = [
'sqlite',
'postgres',
'mysql',
'clickhouse',
'sqlserver',
'bigquery',
'snowflake',
'metabase',
'looker',
'notion',
'dbt',
'metricflow',
'lookml',
];
function normalizedConnectionDriver(project: KtxLocalProject, connectionId: string): string {
return String(project.config.connections[connectionId]?.driver ?? '')
@ -37,33 +65,29 @@ async function testNativeConnection(
project: KtxLocalProject,
connectionId: string,
createScanConnector: typeof createKtxCliScanConnector,
): Promise<{ driver: string; tableCount: number }> {
): Promise<{ driver: string }> {
let connector: KtxScanConnector | null = null;
try {
connector = await createScanConnector(project, connectionId);
const snapshot = await connector.introspect(
{
connectionId,
driver: connector.driver,
mode: 'structural',
dryRun: true,
detectRelationships: false,
},
{ runId: `connection-test-${connectionId}` },
);
return {
driver: connector.driver,
tableCount: snapshot.tables.length,
};
if (!connector.testConnection) {
throw new Error(`Connector for "${connectionId}" does not implement testConnection`);
}
const result = await connector.testConnection();
if (!result.success) {
throw new Error(result.error ?? 'connection test failed');
}
return { driver: connector.driver };
} finally {
await cleanupConnector(connector);
if (connector?.cleanup) {
await connector.cleanup();
}
}
}
async function createDefaultMetabaseClient(
project: KtxLocalProject,
connectionId: string,
): Promise<Pick<MetabaseRuntimeClient, 'testConnection' | 'getDatabases' | 'cleanup'>> {
): Promise<MetabaseTestPort> {
const factory = new DefaultMetabaseConnectionClientFactory(
(metabaseConnectionId) =>
metabaseRuntimeConfigFromLocalConnection(
@ -78,30 +102,282 @@ async function createDefaultMetabaseClient(
async function testMetabaseConnection(
project: KtxLocalProject,
connectionId: string,
createMetabaseClient: typeof createDefaultMetabaseClient,
): Promise<{ driver: 'metabase'; databaseCount: number }> {
let client: Pick<MetabaseRuntimeClient, 'testConnection' | 'getDatabases' | 'cleanup'> | null = null;
createClient: (project: KtxLocalProject, connectionId: string) => Promise<MetabaseTestPort>,
): Promise<{ databaseCount: number }> {
let client: MetabaseTestPort | null = null;
try {
client = await createMetabaseClient(project, connectionId);
client = await createClient(project, connectionId);
const testResult = await client.testConnection();
if (!testResult.success) {
throw new Error(
`Metabase connection test failed: ${testResult.error ?? testResult.message ?? 'unknown error'}`,
);
throw new Error(`Metabase connection test failed: ${testResult.error ?? testResult.message ?? 'unknown error'}`);
}
const databases = await client.getDatabases();
const databaseCount = databases.filter((database) => database.is_sample !== true).length;
if (databaseCount === 0) {
throw new Error('Metabase auth worked but no usable databases were returned');
}
return { driver: 'metabase', databaseCount };
return { databaseCount };
} finally {
await client?.cleanup();
}
}
async function createDefaultLookerClient(
project: KtxLocalProject,
connectionId: string,
): Promise<LookerTestPort> {
const factory = new DefaultLookerConnectionClientFactory(createLocalLookerCredentialResolver(project));
return (await factory.createClient(connectionId)) as unknown as LookerTestPort;
}
async function testLookerConnection(
project: KtxLocalProject,
connectionId: string,
createClient: (project: KtxLocalProject, connectionId: string) => Promise<LookerTestPort>,
): Promise<{ user: string }> {
const client = await createClient(project, connectionId);
const result = await client.testConnection();
if (!result.success) {
throw new Error(`Looker connection test failed: ${result.error ?? 'unknown error'}`);
}
const metadata = (result.metadata ?? {}) as { displayName?: string | null; userId?: string };
const user = (metadata.displayName ?? metadata.userId ?? 'unknown').trim() || 'unknown';
return { user };
}
async function createDefaultNotionClient(
project: KtxLocalProject,
connectionId: string,
): Promise<NotionTestPort> {
const connection = project.config.connections[connectionId];
if (!connection) {
throw new Error(`Connection "${connectionId}" is not configured in ktx.yaml`);
}
const parsed = parseNotionConnectionConfig(connection);
const token = await resolveNotionConnectionAuthToken(parsed);
return new NotionClient(token);
}
function describeNotionBot(bot: NotionBotInfo): string {
const name = typeof bot.name === 'string' ? bot.name.trim() : '';
if (name) return name;
const id = typeof bot.id === 'string' ? bot.id.trim() : '';
return id || 'unknown';
}
async function testNotionConnection(
project: KtxLocalProject,
connectionId: string,
createClient: (project: KtxLocalProject, connectionId: string) => Promise<NotionTestPort>,
): Promise<{ bot: string }> {
const client = await createClient(project, connectionId);
const bot = await client.retrieveBotUser();
return { bot: describeNotionBot(bot) };
}
interface GitConnectionFields {
repoUrl: string;
authToken: string | null;
}
function extractGitConnectionFields(
project: KtxLocalProject,
connectionId: string,
driver: string,
): GitConnectionFields {
const connection = project.config.connections[connectionId];
if (!connection) {
throw new Error(`Connection "${connectionId}" is not configured in ktx.yaml`);
}
const stringField = (value: unknown): string | null =>
typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
const record =
driver === 'metricflow' && typeof connection.metricflow === 'object' && connection.metricflow !== null
? (connection.metricflow as Record<string, unknown>)
: (connection as Record<string, unknown>);
const repoUrl = driver === 'dbt' ? stringField(record.repo_url) : stringField(record.repoUrl);
if (!repoUrl) {
const field = driver === 'dbt' ? 'repo_url' : 'repoUrl';
throw new Error(`Connection "${connectionId}" (driver: ${driver}) is missing ${field}`);
}
const literalToken = stringField(record.auth_token);
const ref = stringField(record.auth_token_ref);
const resolvedRef = ref ? resolveKtxConfigReference(ref, process.env) : null;
return { repoUrl, authToken: literalToken ?? resolvedRef ?? null };
}
async function testGitRepoConnection(
project: KtxLocalProject,
connectionId: string,
driver: string,
runTest: TestRepoConnection,
): Promise<{ repoUrl: string }> {
const { repoUrl, authToken } = extractGitConnectionFields(project, connectionId, driver);
const result = await runTest({ repoUrl, authToken });
if (!result.ok) {
throw new Error(`${driver} repository check failed: ${result.error}`);
}
return { repoUrl };
}
interface DriverTestOutcome {
driver: string;
detailKey: string;
detailValue: string;
}
async function testConnectionByDriver(
project: KtxLocalProject,
connectionId: string,
deps: KtxConnectionDeps,
): Promise<DriverTestOutcome> {
const driver = normalizedConnectionDriver(project, connectionId);
if (!driver) {
throw new Error(`Connection "${connectionId}" has no \`driver\` field in ktx.yaml`);
}
if (driver === 'metabase') {
const result = await testMetabaseConnection(
project,
connectionId,
deps.createMetabaseClient ?? createDefaultMetabaseClient,
);
return { driver, detailKey: 'Databases', detailValue: String(result.databaseCount) };
}
if (driver === 'looker') {
const result = await testLookerConnection(
project,
connectionId,
deps.createLookerClient ?? createDefaultLookerClient,
);
return { driver, detailKey: 'User', detailValue: result.user };
}
if (driver === 'notion') {
const result = await testNotionConnection(
project,
connectionId,
deps.createNotionClient ?? createDefaultNotionClient,
);
return { driver, detailKey: 'Bot', detailValue: result.bot };
}
if (driver === 'dbt' || driver === 'metricflow' || driver === 'lookml') {
const result = await testGitRepoConnection(
project,
connectionId,
driver,
deps.testRepoConnection ?? testRepoConnection,
);
return { driver, detailKey: 'Repo', detailValue: result.repoUrl };
}
if (
driver === 'sqlite' ||
driver === 'sqlite3' ||
driver === 'postgres' ||
driver === 'postgresql' ||
driver === 'mysql' ||
driver === 'clickhouse' ||
driver === 'sqlserver' ||
driver === 'bigquery' ||
driver === 'snowflake'
) {
const result = await testNativeConnection(
project,
connectionId,
deps.createScanConnector ?? createKtxCliScanConnector,
);
return { driver: result.driver, detailKey: 'Status', detailValue: 'ok' };
}
throw new Error(
`Connection "${connectionId}" uses driver "${driver}", which has no test implementation in ktx. Supported: ${SUPPORTED_TEST_DRIVERS.join(', ')}.`,
);
}
interface ConnectionTestRow {
connectionId: string;
driver: string;
ok: boolean;
detail: string;
}
function visualWidth(text: string): number {
// styleText wraps content in ANSI escape sequences; strip them before measuring.
return text.replace(/\[[0-9;]*m/g, '').length;
}
function padVisual(text: string, width: number): string {
const pad = width - visualWidth(text);
return pad > 0 ? `${text}${' '.repeat(pad)}` : text;
}
function renderTestAll(io: KtxCliIo, rows: ReadonlyArray<ConnectionTestRow>): void {
io.stdout.write(`${SYMBOLS.barStart} connection test --all\n`);
io.stdout.write(`${SYMBOLS.bar}\n`);
if (rows.length === 0) {
io.stdout.write(`${SYMBOLS.barEnd} No connections configured. Run \`ktx setup\` to add one.\n`);
return;
}
const okLabel = green('✓ ok');
const failLabel = red('✗ failed');
const idWidth = Math.max(...rows.map((r) => r.connectionId.length));
const driverWidth = Math.max(...rows.map((r) => r.driver.length));
const statusWidth = Math.max(visualWidth(okLabel), visualWidth(failLabel));
for (const row of rows) {
const id = bold(padVisual(row.connectionId, idWidth));
const driver = dim(padVisual(row.driver, driverWidth));
const status = padVisual(row.ok ? okLabel : failLabel, statusWidth);
const detail = dim(row.detail);
io.stdout.write(`${SYMBOLS.bar} ${SYMBOLS.item} ${id} ${driver} ${status} ${detail}\n`);
}
const failed = rows.filter((r) => !r.ok).length;
const passed = rows.length - failed;
io.stdout.write(`${SYMBOLS.bar}\n`);
const summary =
failed === 0
? `${rows.length} tested ${dim(SYMBOLS.middot)} ${green(`${passed} passed`)}`
: `${rows.length} tested ${dim(SYMBOLS.middot)} ${green(`${passed} passed`)} ${dim(SYMBOLS.middot)} ${red(`${failed} failed`)}`;
io.stdout.write(`${SYMBOLS.barEnd} ${summary}\n`);
}
async function runTestAll(
project: KtxLocalProject,
io: KtxCliIo,
deps: KtxConnectionDeps,
): Promise<number> {
const entries = Object.entries(project.config.connections).sort(([a], [b]) => a.localeCompare(b));
const rows = await Promise.all(
entries.map(async ([connectionId, connection]): Promise<ConnectionTestRow> => {
const declaredDriver = String(connection.driver ?? '').trim().toLowerCase() || 'unknown';
try {
const outcome = await testConnectionByDriver(project, connectionId, deps);
return {
connectionId,
driver: outcome.driver || declaredDriver,
ok: true,
detail: `${outcome.detailKey}: ${outcome.detailValue}`,
};
} catch (error) {
return {
connectionId,
driver: declaredDriver,
ok: false,
detail: error instanceof Error ? error.message : String(error),
};
}
}),
);
renderTestAll(io, rows);
return rows.some((row) => !row.ok) ? 1 : 0;
}
export async function runKtxConnection(
args: KtxConnectionArgs,
io: KtxCliIo = process,
@ -127,26 +403,14 @@ export async function runKtxConnection(
return 0;
}
if (normalizedConnectionDriver(project, args.connectionId) === 'metabase') {
const result = await testMetabaseConnection(
project,
args.connectionId,
deps.createMetabaseClient ?? createDefaultMetabaseClient,
);
io.stdout.write(`Connection test passed: ${args.connectionId}\n`);
io.stdout.write(`Driver: ${result.driver}\n`);
io.stdout.write(`Databases: ${result.databaseCount}\n`);
return 0;
if (args.command === 'test-all') {
return await runTestAll(project, io, deps);
}
const result = await testNativeConnection(
project,
args.connectionId,
deps.createScanConnector ?? createKtxCliScanConnector,
);
const { driver, detailKey, detailValue } = await testConnectionByDriver(project, args.connectionId, deps);
io.stdout.write(`Connection test passed: ${args.connectionId}\n`);
io.stdout.write(`Driver: ${result.driver}\n`);
io.stdout.write(`Tables: ${result.tableCount}\n`);
io.stdout.write(`Driver: ${driver}\n`);
io.stdout.write(`${detailKey}: ${detailValue}\n`);
return 0;
} catch (error) {
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);

View file

@ -40,7 +40,7 @@ function projectWithConnections(connections: KtxProjectConfig['connections']): K
return {
projectDir: '/tmp/project',
config: {
...buildDefaultKtxProjectConfig('warehouse'),
...buildDefaultKtxProjectConfig(),
connections,
},
};

View file

@ -52,7 +52,6 @@ export function defaultDemoProjectDir(): string {
function demoConfig(databasePath: string): string {
return [
'project: ktx-demo-orbit',
'connections:',
` ${DEMO_CONNECTION_ID}:`,
' driver: sqlite',

View file

@ -72,10 +72,10 @@ describe('dev Commander tree', () => {
const testIo = makeIo();
try {
await expect(runKtxCli(['dev', 'init', projectDir, '--name', 'warehouse'], testIo.io)).resolves.toBe(0);
await expect(runKtxCli(['dev', 'init', projectDir], testIo.io)).resolves.toBe(0);
expect(testIo.stdout()).toContain(`Initialized KTX project at ${projectDir}`);
await expect(readFile(join(projectDir, 'ktx.yaml'), 'utf-8')).resolves.toContain('project: warehouse');
await expect(readFile(join(projectDir, 'ktx.yaml'), 'utf-8')).resolves.not.toContain('project:');
expect(testIo.stderr()).toBe('');
} finally {
await rm(tempDir, { recursive: true, force: true });
@ -92,7 +92,7 @@ describe('dev Commander tree', () => {
try {
await expect(
runKtxCli(['--project-dir', projectDir, 'dev', 'init', '--name', 'global-init'], testIo.io),
runKtxCli(['--project-dir', projectDir, 'dev', 'init'], testIo.io),
).resolves.toBe(0);
expect(testIo.stdout()).toContain(`Initialized KTX project at ${projectDir}`);

View file

@ -25,19 +25,17 @@ export function registerDevCommands(program: Command, context: KtxCliCommandCont
.command('init')
.description('Initialize a Git-backed KTX project directory for maintenance scripts')
.argument('[directory]', 'Project directory')
.option('--name <name>', 'Project name written to ktx.yaml')
.option('--force', 'Rewrite ktx.yaml and scaffold files in an existing project', false)
.action(
async (
projectDir: string | undefined,
commandOptions: { name?: string; force?: boolean },
commandOptions: { force?: boolean },
command: CommandWithGlobalOptions,
) => {
context.setExitCode(
await context.runInit(
{
projectDir: projectDir ? resolve(projectDir) : resolveCommandProjectDir(command),
...(commandOptions.name ? { projectName: commandOptions.name } : {}),
force: commandOptions.force === true,
},
context.io,
@ -46,5 +44,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);
}

View file

@ -1,6 +1,6 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { basename, join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
formatDoctorReport,
@ -324,12 +324,98 @@ describe('runKtxDoctor', () => {
expect(parsed.projectDir).toBe(tempDir);
});
it('prints schema issues and exits 1 when ktx.yaml fails Zod validation', async () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'storrage:',
' state: sqlite',
'ingest:',
' llm:',
' backend: anthropic',
'',
].join('\n'),
'utf-8',
);
const testIo = makeIo();
await expect(
runKtxDoctor(
{ command: 'project', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' },
testIo.io,
{},
),
).resolves.toBe(1);
const out = testIo.stdout();
expect(out).toContain('KTX status');
expect(out).toContain('Config');
expect(out).toContain('Unsupported storrage: unknown field');
expect(out).toContain('Unsupported ingest.llm: use top-level llm.provider');
expect(out).toContain('ktx.yaml');
});
it('emits structured JSON when ktx.yaml fails Zod validation', async () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
['storrage: {}', ''].join('\n'),
'utf-8',
);
const testIo = makeIo();
await expect(
runKtxDoctor(
{ command: 'project', projectDir: tempDir, outputMode: 'json', inputMode: 'disabled' },
testIo.io,
{},
),
).resolves.toBe(1);
const parsed = JSON.parse(testIo.stdout()) as {
error: string;
projectDir: string;
issues: Array<{ path: string; message: string }>;
};
expect(parsed.error).toBe('invalid_config');
expect(parsed.projectDir).toBe(tempDir);
expect(parsed.issues.some((issue) => issue.path === 'storrage')).toBe(true);
});
it('shows a Config row labelled "ktx.yaml schema valid" on the happy path', async () => {
process.env.ANTHROPIC_API_KEY = 'test-key'; // pragma: allowlist secret
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'connections:',
' warehouse:',
' driver: sqlite',
' path: ./warehouse.db',
'llm:',
' provider:',
' backend: anthropic',
'',
].join('\n'),
'utf-8',
);
const testIo = makeIo();
await expect(
runKtxDoctor(
{ command: 'project', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' },
testIo.io,
{},
),
).resolves.toBe(0);
expect(testIo.stdout()).toContain('ktx.yaml schema valid');
delete process.env.ANTHROPIC_API_KEY;
});
it('runs project checks against a valid ktx.yaml', async () => {
process.env.ANTHROPIC_API_KEY = 'test-key'; // pragma: allowlist secret
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: sqlite',
@ -363,7 +449,7 @@ describe('runKtxDoctor', () => {
const out = testIo.stdout();
expect(out).toContain('KTX status');
expect(out).toContain('· warehouse');
expect(out).toContain(`· ${basename(tempDir)}`);
expect(out).toContain('Connections (1)');
expect(out).toContain('LLM');
expect(out).toContain('anthropic');
@ -379,7 +465,6 @@ describe('runKtxDoctor', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: postgres',
@ -439,7 +524,6 @@ describe('runKtxDoctor', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: sqlite',
@ -469,7 +553,6 @@ describe('runKtxDoctor', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: postgres',
@ -531,7 +614,6 @@ describe('runKtxDoctor', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: sqlite',
@ -565,4 +647,169 @@ describe('runKtxDoctor', () => {
expect(testIo.stdout()).toContain('semantic search degraded');
delete process.env.ANTHROPIC_API_KEY;
});
describe('command: validate', () => {
it('prints a success line and exits 0 when ktx.yaml is schema-valid', async () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'connections:',
' warehouse:',
' driver: sqlite',
' path: ./warehouse.db',
'llm:',
' provider:',
' backend: anthropic',
'',
].join('\n'),
'utf-8',
);
const testIo = makeIo();
await expect(
runKtxDoctor(
{ command: 'validate', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' },
testIo.io,
{},
),
).resolves.toBe(0);
const out = testIo.stdout();
expect(out).toContain('KTX status');
expect(out).toContain('Config');
expect(out).toContain('ktx.yaml schema valid');
expect(out).not.toContain('LLM');
expect(out).not.toContain('Connections');
expect(out).not.toContain('Pipeline');
});
it('emits {ok: true} JSON when ktx.yaml is schema-valid', async () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'connections:',
' warehouse:',
' driver: sqlite',
' path: ./warehouse.db',
'llm:',
' provider:',
' backend: anthropic',
'',
].join('\n'),
'utf-8',
);
const testIo = makeIo();
await expect(
runKtxDoctor(
{ command: 'validate', projectDir: tempDir, outputMode: 'json', inputMode: 'disabled' },
testIo.io,
{},
),
).resolves.toBe(0);
expect(JSON.parse(testIo.stdout())).toEqual({ ok: true, projectDir: tempDir });
});
it('prints schema issues and exits 1 when ktx.yaml fails Zod validation', async () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'storrage:',
' state: sqlite',
'ingest:',
' llm:',
' backend: anthropic',
'',
].join('\n'),
'utf-8',
);
const testIo = makeIo();
await expect(
runKtxDoctor(
{ command: 'validate', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' },
testIo.io,
{},
),
).resolves.toBe(1);
const out = testIo.stdout();
expect(out).toContain('Unsupported storrage: unknown field');
expect(out).toContain('Unsupported ingest.llm: use top-level llm.provider');
});
it('emits structured JSON issues when validation fails', async () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
['storrage: {}', ''].join('\n'),
'utf-8',
);
const testIo = makeIo();
await expect(
runKtxDoctor(
{ command: 'validate', projectDir: tempDir, outputMode: 'json', inputMode: 'disabled' },
testIo.io,
{},
),
).resolves.toBe(1);
const parsed = JSON.parse(testIo.stdout()) as { error: string; issues: Array<{ path: string }> };
expect(parsed.error).toBe('invalid_config');
expect(parsed.issues.some((issue) => issue.path === 'storrage')).toBe(true);
});
it('prints the missing-project message and exits 1 when ktx.yaml is absent', async () => {
const testIo = makeIo();
await expect(
runKtxDoctor(
{ command: 'validate', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' },
testIo.io,
{},
),
).resolves.toBe(1);
expect(testIo.stdout()).toContain('No KTX project here yet.');
});
it('does not invoke the Postgres query-history probe in validate mode', async () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'connections:',
' warehouse:',
' driver: postgres',
' url: env:WAREHOUSE_DATABASE_URL',
' context:',
' queryHistory:',
' enabled: true',
'llm:',
' provider:',
' backend: anthropic',
'',
].join('\n'),
'utf-8',
);
const testIo = makeIo();
let probeCalls = 0;
await expect(
runKtxDoctor(
{ command: 'validate', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' },
testIo.io,
{
postgresQueryHistoryProbe: async () => {
probeCalls += 1;
return { pgServerVersion: 'PostgreSQL 16.4', warnings: [], info: [] };
},
},
),
).resolves.toBe(0);
expect(probeCalls).toBe(0);
expect(testIo.stdout()).toContain('ktx.yaml schema valid');
});
});
});

View file

@ -1,9 +1,10 @@
import { execFile } from 'node:child_process';
import { constants as fsConstants } from 'node:fs';
import { access } from 'node:fs/promises';
import { access, readFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
import type { KtxConfigIssue } from '@ktx/context/project';
import type { BuildProjectStatusOptions } from './status-project.js';
const execFileAsync = promisify(execFile);
@ -40,6 +41,12 @@ export type KtxDoctorArgs =
outputMode: KtxDoctorOutputMode;
inputMode?: KtxDoctorInputMode;
verbose?: boolean;
}
| {
command: 'validate';
projectDir: string;
outputMode: KtxDoctorOutputMode;
inputMode?: KtxDoctorInputMode;
};
interface KtxDoctorIo {
@ -450,6 +457,84 @@ function writeReport(report: DoctorReport, outputMode: KtxDoctorOutputMode, io:
io.stdout.write(renderPlainReport(report, options));
}
export function renderInvalidConfigMessage(
projectDir: string,
issues: KtxConfigIssue[],
outputMode: KtxDoctorOutputMode,
io: KtxDoctorIo,
): void {
if (outputMode === 'json') {
io.stdout.write(
`${JSON.stringify(
{
error: 'invalid_config',
projectDir,
issues,
},
null,
2,
)}\n`,
);
return;
}
const useColor = shouldUseColor(io);
const dim = (text: string) => styleDim(useColor, text);
const bold = (text: string) => styleBold(useColor, text);
const status = (s: DoctorStatus, text: string) => styleStatus(useColor, s, text);
const abbreviated = abbreviateHome(projectDir) ?? projectDir;
const lines: string[] = [];
lines.push(`${bold('KTX status')} ${dim('·')} ${abbreviated}`);
lines.push('');
lines.push(` ${status('fail', '✗')} ${bold('Config')} ktx.yaml has ${issues.length} schema issue${issues.length === 1 ? '' : 's'}`);
for (const issue of issues) {
lines.push(` ${status('fail', '✗')} ${issue.message}`);
if (issue.fix) {
lines.push(` ${dim(`${issue.fix}`)}`);
}
}
lines.push('');
lines.push(` ${dim('Fix the issues in')} ${join(abbreviated, 'ktx.yaml')} ${dim('and rerun')} ${bold('ktx status')}.`);
lines.push('');
io.stdout.write(lines.join('\n'));
}
export function renderValidConfigMessage(
projectDir: string,
outputMode: KtxDoctorOutputMode,
io: KtxDoctorIo,
): void {
if (outputMode === 'json') {
io.stdout.write(
`${JSON.stringify(
{
ok: true,
projectDir,
},
null,
2,
)}\n`,
);
return;
}
const useColor = shouldUseColor(io);
const dim = (text: string) => styleDim(useColor, text);
const bold = (text: string) => styleBold(useColor, text);
const status = (s: DoctorStatus, text: string) => styleStatus(useColor, s, text);
const abbreviated = abbreviateHome(projectDir) ?? projectDir;
const lines: string[] = [];
lines.push(`${bold('KTX status')} ${dim('·')} ${abbreviated}`);
lines.push('');
lines.push(` ${status('pass', '✓')} ${bold('Config')} ${dim('ktx.yaml schema valid')}`);
lines.push('');
io.stdout.write(lines.join('\n'));
}
export function renderMissingProjectMessage(
projectDir: string,
outputMode: KtxDoctorOutputMode,
@ -501,16 +586,39 @@ export async function runKtxDoctor(
try {
const runSetupChecks = deps.runSetupChecks ?? (() => runSetupDoctorChecks());
if (args.command === 'validate') {
const configPath = join(args.projectDir, 'ktx.yaml');
if (!(await defaultPathExists(configPath))) {
renderMissingProjectMessage(args.projectDir, args.outputMode, io);
return 1;
}
const { validateKtxProjectConfig } = await import('@ktx/context/project');
const rawConfig = await readFile(configPath, 'utf-8');
const validation = validateKtxProjectConfig(rawConfig);
if (!validation.ok) {
renderInvalidConfigMessage(args.projectDir, validation.issues, args.outputMode, io);
return 1;
}
renderValidConfigMessage(args.projectDir, args.outputMode, io);
return 0;
}
if (args.command === 'project') {
const configPath = join(args.projectDir, 'ktx.yaml');
if (!(await defaultPathExists(configPath))) {
renderMissingProjectMessage(args.projectDir, args.outputMode, io);
return 1;
}
const { loadKtxProject } = await import('@ktx/context/project');
const { loadKtxProject, validateKtxProjectConfig } = await import('@ktx/context/project');
const { buildProjectStatus, renderProjectStatus } = await import('./status-project.js');
const rawConfig = await readFile(configPath, 'utf-8');
const validation = validateKtxProjectConfig(rawConfig);
if (!validation.ok) {
renderInvalidConfigMessage(args.projectDir, validation.issues, args.outputMode, io);
return 1;
}
const project = await loadKtxProject({ projectDir: args.projectDir });
const projectStatus = await buildProjectStatus(project, deps);
const projectStatus = await buildProjectStatus(project, { ...deps, configIssues: validation.issues });
const verbose = args.verbose ?? false;
const toolchainChecks = verbose ? await runSetupChecks() : undefined;
if (args.outputMode === 'json') {

View file

@ -102,7 +102,7 @@ describe('runKtxCli', () => {
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'ktx-cli-'));
await writeFile(join(tempDir, 'ktx.yaml'), 'project: cli-dispatch-fixture\n', 'utf-8');
await writeFile(join(tempDir, 'ktx.yaml'), '{}\n', 'utf-8');
});
afterEach(async () => {
@ -503,7 +503,7 @@ describe('runKtxCli', () => {
it('keeps representative JSON command stdout parseable', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir, projectName: 'warehouse' });
await initKtxProject({ projectDir });
const commands = [
['--project-dir', projectDir, 'status', '--json'],
['--project-dir', projectDir, 'sl', 'list', '--json'],
@ -581,7 +581,7 @@ describe('runKtxCli', () => {
try {
delete process.env.KTX_PROJECT_DIR;
await writeFile(join(tempDir, 'ktx.yaml'), 'project: revenue\nconnections: {}\n', 'utf-8');
await writeFile(join(tempDir, 'ktx.yaml'), 'connections: {}\n', 'utf-8');
process.chdir(tempDir);
await expect(runKtxCli([], testIo.io, { setup })).resolves.toBe(0);
@ -1515,7 +1515,7 @@ describe('runKtxCli', () => {
it('dispatches public connection subcommands through the existing connection implementation', async () => {
const tempDir = await mkdtemp(join(tmpdir(), 'ktx-connection-dispatch-'));
await writeFile(join(tempDir, 'ktx.yaml'), 'project: connection-dispatch\n', 'utf-8');
await writeFile(join(tempDir, 'ktx.yaml'), '{}\n', 'utf-8');
const connection = vi.fn(async () => 0);
await expect(
@ -1550,7 +1550,7 @@ describe('runKtxCli', () => {
expect(helpIo.stdout()).toContain('Usage: ktx connection');
expect(helpIo.stdout()).toContain('list');
expect(helpIo.stdout()).toContain('test <connectionId>');
expect(helpIo.stdout()).toContain('test [options] [connectionId]');
for (const removed of ['add', 'remove', 'map', 'mapping', 'metabase', 'notion']) {
expect(helpIo.stdout()).not.toMatch(new RegExp(`\\b${removed}\\b`));
}

View file

@ -106,7 +106,6 @@ export async function writeWarehouseConfig(projectDir: string): Promise<void> {
await writeFile(
join(projectDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' prod-metabase:',
' driver: metabase',
@ -126,7 +125,6 @@ export async function writeMetabaseConfig(projectDir: string): Promise<void> {
await writeFile(
join(projectDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: postgres',
@ -488,7 +486,6 @@ export async function runPublicMetabaseSyncModeCase(tempDir: string, input: Sync
await writeFile(
join(projectDir, 'ktx.yaml'),
[
`project: metabase-sync-mode-${input.name}`,
'connections:',
' prod-metabase:',
' driver: metabase',

View file

@ -278,7 +278,7 @@ describe('runKtxIngest', () => {
{
databasesDeps: {
testConnection: async (_projectDir, _connectionId, io) => {
io.stdout.write('Driver: postgres\nTables: 1\n');
io.stdout.write('Driver: postgres\nStatus: ok\n');
return 0;
},
scanConnection: async () => 0,
@ -633,7 +633,6 @@ describe('runKtxIngest', () => {
await writeFile(
join(projectDir, 'ktx.yaml'),
[
'project: metabase-cli',
'connections:',
' prod-metabase:',
' driver: metabase',
@ -1099,7 +1098,7 @@ describe('runKtxIngest', () => {
it('passes managed daemon options to adapters and pull-config options when no explicit daemon URL is set', async () => {
const projectDir = join(tempDir, 'managed-daemon-ingest-project');
await initKtxProject({ projectDir, projectName: 'managed-daemon-ingest-project' });
await initKtxProject({ projectDir });
await writeWarehouseConfig(projectDir);
const createdAdapters: SourceAdapter[] = [
{ source: 'fake', skillNames: [], detect: async () => true, chunk: async () => ({ workUnits: [] }) },
@ -1159,7 +1158,6 @@ describe('runKtxIngest', () => {
await writeFile(
join(projectDir, 'ktx.yaml'),
[
'project: historic-sql-project',
'connections:',
' warehouse:',
' driver: postgres',
@ -1224,7 +1222,6 @@ describe('runKtxIngest', () => {
await writeFile(
join(projectDir, 'ktx.yaml'),
[
'project: historic-sql-progress-project',
'connections:',
' warehouse:',
' driver: postgres',
@ -1353,7 +1350,6 @@ describe('runKtxIngest', () => {
await writeFile(
join(projectDir, 'ktx.yaml'),
[
'project: historic-sql-step-progress-project',
'connections:',
' warehouse:',
' driver: postgres',
@ -1446,7 +1442,6 @@ describe('runKtxIngest', () => {
await writeFile(
join(projectDir, 'ktx.yaml'),
[
'project: historic-sql-concurrent-progress-project',
'connections:',
' warehouse:',
' driver: postgres',
@ -1596,7 +1591,6 @@ describe('runKtxIngest', () => {
await writeFile(
join(projectDir, 'ktx.yaml'),
[
'project: looker-cli',
'connections:',
' prod-looker:',
' driver: looker',

View file

@ -35,3 +35,11 @@ export function bold(text: string): string {
export function gray(text: string): string {
return styleText('gray', text);
}
export function green(text: string): string {
return styleText('green', text);
}
export function red(text: string): string {
return styleText('red', text);
}

View file

@ -53,7 +53,7 @@ describe('runKtxKnowledge', () => {
it('writes, reads, lists, and searches wiki pages', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir, projectName: 'warehouse' });
await initKtxProject({ projectDir });
const writeIo = makeIo();
await expect(
@ -95,7 +95,7 @@ describe('runKtxKnowledge', () => {
it('prints wiki list, search, and read as public JSON envelopes', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir, projectName: 'warehouse' });
await initKtxProject({ projectDir });
await expect(
runKtxKnowledge(
@ -154,7 +154,7 @@ describe('runKtxKnowledge', () => {
it('rejects slash-delimited write keys with a flat-key suggestion', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir, projectName: 'warehouse' });
await initKtxProject({ projectDir });
const writeIo = makeIo();
await expect(
@ -183,7 +183,7 @@ describe('runKtxKnowledge', () => {
it('explains empty search results for a project without wiki pages', async () => {
const projectDir = join(tempDir, 'empty-project');
await initKtxProject({ projectDir, projectName: 'warehouse' });
await initKtxProject({ projectDir });
const searchIo = makeIo();
await expect(
@ -197,7 +197,7 @@ describe('runKtxKnowledge', () => {
it('uses configured embeddings for semantic wiki search', async () => {
const projectDir = join(tempDir, 'semantic-project');
await initKtxProject({ projectDir, projectName: 'warehouse' });
await initKtxProject({ projectDir });
await expect(
runKtxKnowledge(

View file

@ -43,7 +43,6 @@ describe('CLI local ingest adapters', () => {
await writeProject(
tempDir,
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: postgres',
@ -74,7 +73,6 @@ describe('CLI local ingest adapters', () => {
await writeProject(
tempDir,
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: postgres',
@ -106,7 +104,6 @@ describe('CLI local ingest adapters', () => {
await writeProject(
tempDir,
[
'project: warehouse',
'connections:',
' bq:',
' driver: bigquery',
@ -139,7 +136,6 @@ describe('CLI local ingest adapters', () => {
await writeProject(
tempDir,
[
'project: warehouse',
'connections:',
' sf:',
' driver: snowflake',
@ -175,7 +171,6 @@ describe('CLI local ingest adapters', () => {
await writeProject(
tempDir,
[
'project: warehouse',
'connections:',
' bq:',
' driver: bigquery',

View file

@ -39,11 +39,10 @@ describe('createKtxCliScanConnector', () => {
});
it('creates a native sqlite connector from standalone config', async () => {
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
await initKtxProject({ projectDir: tempDir });
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: sqlite',
@ -61,11 +60,10 @@ describe('createKtxCliScanConnector', () => {
});
it('passes canonical BigQuery YAML scan limits through to the connector', async () => {
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
await initKtxProject({ projectDir: tempDir });
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: bigquery',
@ -95,11 +93,10 @@ describe('createKtxCliScanConnector', () => {
});
it('throws for structural daemon-only fallback configs', async () => {
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
await initKtxProject({ projectDir: tempDir });
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: duckdb',
@ -116,11 +113,10 @@ describe('createKtxCliScanConnector', () => {
});
it('throws a clear error when the connection block has no driver field', async () => {
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
await initKtxProject({ projectDir: tempDir });
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' type: postgres',

View file

@ -16,7 +16,7 @@ describe('renderKtxCommandTree', () => {
expect(topLevel).toContain(expected);
}
expect(output).toContain('│ └── test <connectionId>');
expect(output).toContain('│ └── test [connectionId]');
expect(output).toContain('│ ├── status Show KTX MCP daemon status');
expect(output).not.toContain('│ ├── add');
expect(output).not.toContain('│ ├── remove');

View file

@ -6,7 +6,7 @@ import { runKtxCli, type KtxCliDeps } from './index.js';
async function makeFixtureProject(prefix: string): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), prefix));
await writeFile(join(dir, 'ktx.yaml'), 'project: project-dir-fixture\n', 'utf-8');
await writeFile(join(dir, 'ktx.yaml'), '{}\n', 'utf-8');
return dir;
}
@ -138,7 +138,7 @@ describe('project directory defaults', () => {
const projectDir = join(root, 'warehouse');
const nestedDir = join(projectDir, 'nested', 'deeper');
await mkdir(nestedDir, { recursive: true });
await writeFile(join(projectDir, 'ktx.yaml'), 'project: warehouse\n', 'utf-8');
await writeFile(join(projectDir, 'ktx.yaml'), '{}\n', 'utf-8');
const expectedProjectDir = await realpath(projectDir);
const publicIngest = vi.fn(async () => 0);

View file

@ -48,7 +48,7 @@ describe('resolveKtxProjectDir', () => {
const project = join(tempDir, 'warehouse');
const nested = join(project, 'nested', 'deeper');
await mkdir(nested, { recursive: true });
await writeFile(join(project, 'ktx.yaml'), 'project: warehouse\n', 'utf-8');
await writeFile(join(project, 'ktx.yaml'), '{}\n', 'utf-8');
expect(resolveKtxProjectDir({ env: {}, cwd: nested })).toBe(resolve(project));
expect(findNearestKtxProjectDir(nested)).toBe(resolve(project));

View file

@ -41,7 +41,7 @@ function projectWithConnections(connections: KtxProjectConfig['connections']): K
return {
projectDir: '/tmp/project',
config: {
...buildDefaultKtxProjectConfig('warehouse'),
...buildDefaultKtxProjectConfig(),
connections,
},
};
@ -51,7 +51,7 @@ function deepReadyProject(
connections: KtxProjectConfig['connections'],
relationshipsEnabled = true,
): KtxPublicIngestProject {
const config = buildDefaultKtxProjectConfig('warehouse');
const config = buildDefaultKtxProjectConfig();
return {
projectDir: '/tmp/project',
config: {

View file

@ -316,7 +316,7 @@ describe('runKtxScan', () => {
});
it('runs structural scans and prints a dev-friendly plain summary', async () => {
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
await initKtxProject({ projectDir: tempDir });
const runLocalScan = vi.fn(
async (_input: RunLocalScanOptions): Promise<LocalScanRunResult> => ({
runId: 'scan-run-1',
@ -377,7 +377,7 @@ describe('runKtxScan', () => {
});
it('passes managed daemon options to local ingest adapters when no explicit daemon URL is set', async () => {
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
await initKtxProject({ projectDir: tempDir });
const createLocalIngestAdapters = vi.fn(() => []);
const runLocalScan = vi.fn(
async (_input: RunLocalScanOptions): Promise<LocalScanRunResult> => ({
@ -421,7 +421,7 @@ describe('runKtxScan', () => {
});
it('explains warnings, capability gaps, and relationships in human scan summaries', async () => {
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
await initKtxProject({ projectDir: tempDir });
const runLocalScan = vi.fn(
async (_input: RunLocalScanOptions): Promise<LocalScanRunResult> => ({
runId: 'scan-run-1',
@ -472,7 +472,7 @@ describe('runKtxScan', () => {
});
it('prints review-only relationship summaries and validation capability warnings', async () => {
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
await initKtxProject({ projectDir: tempDir });
const reviewOnlyReport: KtxScanReport = {
...reportWithAttention,
capabilityGaps: [],
@ -525,7 +525,7 @@ describe('runKtxScan', () => {
});
it('passes a scan progress port and prints TTY progress messages', async () => {
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
await initKtxProject({ projectDir: tempDir });
const runLocalScan = vi.fn(async (input: RunLocalScanOptions): Promise<LocalScanRunResult> => {
await input.progress?.update(0.15, 'Inspecting database schema');
await input.progress?.update(0.55, 'Semantic layer comparison found 5 changes across 18 tables');
@ -572,7 +572,7 @@ describe('runKtxScan', () => {
});
it('uses injected structured progress without requiring TTY progress output', async () => {
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
await initKtxProject({ projectDir: tempDir });
const progressEvents: Array<{ progress: number; message?: string; transient?: boolean }> = [];
const structuredProgress = {
async update(progress: number, message?: string, options?: { transient?: boolean }) {
@ -674,7 +674,7 @@ describe('runKtxScan', () => {
});
it('flushes transient TTY progress messages before printing scan failures', async () => {
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
await initKtxProject({ projectDir: tempDir });
const runLocalScan = vi.fn(async (input: RunLocalScanOptions): Promise<LocalScanRunResult> => {
await input.progress?.update(0.42, 'Generating descriptions 3/35 tables', { transient: true });
throw new Error('scan failed');
@ -711,7 +711,7 @@ describe('runKtxScan', () => {
});
it('does not print live progress messages for non-TTY output', async () => {
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
await initKtxProject({ projectDir: tempDir });
const runLocalScan = vi.fn(async (input: RunLocalScanOptions): Promise<LocalScanRunResult> => {
await input.progress?.update(0.15, 'Inspecting database schema');
return {
@ -747,7 +747,7 @@ describe('runKtxScan', () => {
});
it('uses terminal-aware visual styling only for TTY output', async () => {
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
await initKtxProject({ projectDir: tempDir });
const runLocalScan = vi.fn(
async (_input: RunLocalScanOptions): Promise<LocalScanRunResult> => ({
runId: 'scan-run-1',
@ -807,7 +807,7 @@ describe('runKtxScan', () => {
});
it('honors NO_COLOR for TTY scan summaries', async () => {
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
await initKtxProject({ projectDir: tempDir });
const runLocalScan = vi.fn(
async (_input: RunLocalScanOptions): Promise<LocalScanRunResult> => ({
runId: 'scan-run-1',
@ -853,11 +853,10 @@ describe('runKtxScan', () => {
it('passes native CLI adapters into local scan runs for mysql configs', async () => {
const tempProject = await mkdtemp(join(tmpdir(), 'ktx-scan-cli-native-'));
await initKtxProject({ projectDir: tempProject, projectName: 'warehouse' });
await initKtxProject({ projectDir: tempProject });
await writeFile(
join(tempProject, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: mysql',
@ -901,11 +900,10 @@ describe('runKtxScan', () => {
it('creates a native connector for standalone relationship scans', async () => {
const tempProject = await mkdtemp(join(tmpdir(), 'ktx-scan-cli-relationships-'));
await initKtxProject({ projectDir: tempProject, projectName: 'warehouse' });
await initKtxProject({ projectDir: tempProject });
await writeFile(
join(tempProject, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: sqlite',
@ -955,11 +953,10 @@ describe('runKtxScan', () => {
it('routes standalone postgres scans through the native connector before daemon fallback', async () => {
const tempProject = await mkdtemp(join(tmpdir(), 'ktx-scan-cli-native-postgres-'));
await initKtxProject({ projectDir: tempProject, projectName: 'warehouse' });
await initKtxProject({ projectDir: tempProject });
await writeFile(
join(tempProject, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: postgres',
@ -1021,11 +1018,10 @@ describe('runKtxScan', () => {
it('passes native CLI adapters into local scan runs for clickhouse configs', async () => {
const tempProject = await mkdtemp(join(tmpdir(), 'ktx-scan-cli-native-clickhouse-'));
await initKtxProject({ projectDir: tempProject, projectName: 'warehouse' });
await initKtxProject({ projectDir: tempProject });
await writeFile(
join(tempProject, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: clickhouse',
@ -1072,11 +1068,10 @@ describe('runKtxScan', () => {
it('passes native CLI adapters into local scan runs for sqlserver configs', async () => {
const tempProject = await mkdtemp(join(tmpdir(), 'ktx-scan-cli-native-sqlserver-'));
await initKtxProject({ projectDir: tempProject, projectName: 'warehouse' });
await initKtxProject({ projectDir: tempProject });
await writeFile(
join(tempProject, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: sqlserver',
@ -1138,11 +1133,10 @@ describe('runKtxScan', () => {
it('passes native CLI adapters into local scan runs for bigquery configs', async () => {
const tempProject = await mkdtemp(join(tmpdir(), 'ktx-scan-cli-native-bigquery-'));
await initKtxProject({ projectDir: tempProject, projectName: 'warehouse' });
await initKtxProject({ projectDir: tempProject });
await writeFile(
join(tempProject, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: bigquery',
@ -1203,11 +1197,10 @@ describe('runKtxScan', () => {
it('passes native CLI adapters into local scan runs for snowflake configs', async () => {
const tempProject = await mkdtemp(join(tmpdir(), 'ktx-scan-cli-native-snowflake-'));
await initKtxProject({ projectDir: tempProject, projectName: 'warehouse' });
await initKtxProject({ projectDir: tempProject });
await writeFile(
join(tempProject, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: snowflake',

View file

@ -30,7 +30,7 @@ describe('setup agents', () => {
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'ktx-setup-agents-'));
await mkdir(join(tempDir, '.ktx', 'agents'), { recursive: true });
await writeFile(join(tempDir, 'ktx.yaml'), 'project: revenue\nconnections: {}\n', 'utf-8');
await writeFile(join(tempDir, 'ktx.yaml'), 'connections: {}\n', 'utf-8');
});
afterEach(async () => {

View file

@ -50,7 +50,7 @@ type ReadyProjectOverrides = Omit<Partial<KtxProjectConfig>, 'ingest' | 'llm' |
};
async function writeReadyProject(projectDir: string, overrides: ReadyProjectOverrides = {}) {
const defaults = buildDefaultKtxProjectConfig('revenue');
const defaults = buildDefaultKtxProjectConfig();
const readyConfig: KtxProjectConfig = {
...defaults,
setup: { database_connection_ids: ['warehouse'] },
@ -595,7 +595,6 @@ describe('setup context build state', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: revenue',
'connections: {}',
'llm:',
' provider:',

View file

@ -119,7 +119,7 @@ describe('setup databases step', () => {
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'ktx-setup-databases-'));
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
await initKtxProject({ projectDir: tempDir });
});
afterEach(async () => {
@ -242,7 +242,6 @@ describe('setup databases step', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: postgres',
@ -575,7 +574,6 @@ describe('setup databases step', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: postgres',
@ -622,7 +620,6 @@ describe('setup databases step', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: postgres',
@ -770,7 +767,6 @@ describe('setup databases step', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: postgres',
@ -815,7 +811,6 @@ describe('setup databases step', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: postgres',
@ -864,7 +859,6 @@ describe('setup databases step', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: postgres',
@ -936,7 +930,6 @@ describe('setup databases step', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: postgres',
@ -1010,7 +1003,6 @@ describe('setup databases step', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: postgres',
@ -1079,7 +1071,6 @@ describe('setup databases step', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: postgres',
@ -1146,7 +1137,6 @@ describe('setup databases step', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: postgres',
@ -1361,7 +1351,7 @@ describe('setup databases step', () => {
const testConnection = vi.fn(async (_projectDir: string, _connectionId: string, commandIo: KtxCliIo) => {
commandIo.stdout.write('Connection test passed: postgres-warehouse\n');
commandIo.stdout.write('Driver: postgres\n');
commandIo.stdout.write('Tables: 2\n');
commandIo.stdout.write('Status: ok\n');
return 0;
});
const scanConnection = vi.fn(async (_projectDir: string, _connectionId: string, commandIo: KtxCliIo) => {
@ -1646,7 +1636,6 @@ describe('setup databases step', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: postgres',
@ -1939,7 +1928,6 @@ describe('setup databases step', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: postgres',
@ -2019,7 +2007,6 @@ describe('setup databases step', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' analytics:',
' driver: bigquery',
@ -2074,7 +2061,6 @@ describe('setup databases step', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: postgres',
@ -2123,7 +2109,6 @@ describe('setup databases step', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: postgres',

View file

@ -60,7 +60,7 @@ describe('setup embeddings step', () => {
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'ktx-setup-embeddings-'));
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
await initKtxProject({ projectDir: tempDir });
});
afterEach(async () => {
@ -446,11 +446,10 @@ describe('setup embeddings step', () => {
it('preserves already completed embeddings setup when no embedding args request changes', async () => {
await mkdir(join(tempDir, '.ktx'), { recursive: true });
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse', force: true });
await initKtxProject({ projectDir: tempDir, force: true });
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'setup:',
' database_connection_ids: []',
'connections: {}',

View file

@ -92,7 +92,7 @@ describe('setup Anthropic model step', () => {
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'ktx-setup-models-'));
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
await initKtxProject({ projectDir: tempDir });
});
afterEach(async () => {
@ -1049,11 +1049,10 @@ describe('setup Anthropic model step', () => {
it('preserves already completed llm setup when no model args request changes', async () => {
await mkdir(join(tempDir, '.ktx'), { recursive: true });
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse', force: true });
await initKtxProject({ projectDir: tempDir, force: true });
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'setup:',
' database_connection_ids: []',
'connections: {}',
@ -1099,7 +1098,6 @@ describe('setup Anthropic model step', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'setup:',
' database_connection_ids: []',
'connections: {}',

View file

@ -76,11 +76,10 @@ describe('setup project step', () => {
it('loads an existing project with --existing and drops config setup progress', async () => {
const projectDir = join(tempDir, 'warehouse');
await initKtxProject({ projectDir, projectName: 'warehouse' });
await initKtxProject({ projectDir });
await writeFile(
join(projectDir, 'ktx.yaml'),
[
'project: warehouse',
'setup:',
' database_connection_ids:',
' - warehouse',
@ -196,7 +195,7 @@ describe('setup project step', () => {
expect.objectContaining({ message: `Create KTX project at ${projectDir}?` }),
);
expect(prompts.text).not.toHaveBeenCalled();
expect(result.status === 'ready' ? result.project.config.project : '').toBe('ktx-project');
expect(result.status === 'ready' ? result.project.configPath : '').toBe(join(projectDir, 'ktx.yaml'));
expect(testIo.stdout()).toContain(`│ KTX will create:\n│ ${projectDir}`);
await expect(stat(join(projectDir, 'ktx.yaml'))).resolves.toBeDefined();
});

View file

@ -1,7 +1,7 @@
import { existsSync } from 'node:fs';
import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { basename, join, resolve } from 'node:path';
import { join, resolve } from 'node:path';
import {
initKtxProject,
type KtxLocalProject,
@ -156,7 +156,7 @@ async function persistProjectStep(project: KtxLocalProject): Promise<KtxLocalPro
async function createProject(projectDir: string, deps: KtxSetupProjectDeps): Promise<KtxLocalProject> {
const initProject = deps.initProject ?? initKtxProject;
const initialized = await initProject({ projectDir, projectName: basename(projectDir) || 'ktx-project' });
const initialized = await initProject({ projectDir });
return await persistProjectStep(initialized);
}

View file

@ -79,7 +79,7 @@ describe('setup sources step', () => {
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'ktx-setup-sources-'));
projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir, projectName: 'sources' });
await initKtxProject({ projectDir });
});
afterEach(async () => {

View file

@ -68,7 +68,6 @@ describe('setup status', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: revenue',
'llm:',
' provider:',
' backend: anthropic',
@ -109,7 +108,6 @@ describe('setup status', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: revenue',
'llm:',
' provider:',
...fixture.providerLines,
@ -129,7 +127,6 @@ describe('setup status', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: revenue',
'setup:',
' database_connection_ids:',
' - warehouse',
@ -162,7 +159,6 @@ describe('setup status', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: revenue',
'setup:',
' database_connection_ids:',
' - warehouse',
@ -183,7 +179,6 @@ describe('setup status', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: revenue',
'setup:',
' database_connection_ids:',
' - warehouse',
@ -206,7 +201,6 @@ describe('setup status', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: revenue',
'setup:',
' database_connection_ids: []',
'connections:',
@ -230,7 +224,7 @@ describe('setup status', () => {
it('reports agent status from the install manifest', async () => {
await mkdir(join(tempDir, '.ktx', 'agents'), { recursive: true });
await writeFile(join(tempDir, 'ktx.yaml'), 'project: revenue\nconnections: {}\n', 'utf-8');
await writeFile(join(tempDir, 'ktx.yaml'), 'connections: {}\n', 'utf-8');
await writeFile(
join(tempDir, '.ktx/agents/install-manifest.json'),
JSON.stringify(
@ -256,7 +250,6 @@ describe('setup status', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: revenue',
'setup:',
' database_connection_ids:',
' - warehouse',
@ -309,7 +302,6 @@ describe('setup status', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: revenue',
'setup:',
' database_connection_ids:',
' - warehouse',
@ -370,7 +362,7 @@ describe('setup status', () => {
});
it('prints the readiness checklist for an existing project', async () => {
await writeFile(join(tempDir, 'ktx.yaml'), 'project: revenue\nconnections: {}\n', 'utf-8');
await writeFile(join(tempDir, 'ktx.yaml'), 'connections: {}\n', 'utf-8');
const rendered = formatKtxSetupStatus(await readKtxSetupStatus(tempDir));
@ -503,7 +495,7 @@ describe('setup status', () => {
),
).resolves.toBe(0);
await writeFile(join(tempDir, 'ktx.yaml'), 'project: revenue\nconnections: {}\n', 'utf-8');
await writeFile(join(tempDir, 'ktx.yaml'), 'connections: {}\n', 'utf-8');
await expect(
runKtxSetup(
@ -589,7 +581,7 @@ describe('setup status', () => {
});
it('lets Back from new project creation return to the first setup intent menu', async () => {
const existingConfig = 'project: revenue\nconnections: {}\n';
const existingConfig = 'connections: {}\n';
await writeFile(join(tempDir, 'ktx.yaml'), existingConfig, 'utf-8');
const entryChoices = ['new-project', 'exit'];
@ -645,7 +637,7 @@ describe('setup status', () => {
const existingProjectDir = join(tempDir, 'existing');
const newProjectDir = join(tempDir, 'fresh');
await mkdir(existingProjectDir, { recursive: true });
const existingConfig = 'project: revenue\nconnections: {}\n';
const existingConfig = 'connections: {}\n';
await writeFile(join(existingProjectDir, 'ktx.yaml'), existingConfig, 'utf-8');
const projectChoices = ['custom', 'create'];
@ -722,7 +714,7 @@ describe('setup status', () => {
const existingProjectDir = join(tempDir, 'existing');
const newProjectDir = join(tempDir, 'fresh');
await mkdir(existingProjectDir, { recursive: true });
await writeFile(join(existingProjectDir, 'ktx.yaml'), 'project: revenue\nconnections: {}\n', 'utf-8');
await writeFile(join(existingProjectDir, 'ktx.yaml'), 'connections: {}\n', 'utf-8');
const projectChoices = ['custom', 'create'];
const projectPrompts = {
@ -1147,7 +1139,7 @@ describe('setup status', () => {
});
it('lets Back from the first setup step return to the entry menu instead of exiting', async () => {
await writeFile(join(tempDir, 'ktx.yaml'), 'project: test\nconnections: {}\n', 'utf-8');
await writeFile(join(tempDir, 'ktx.yaml'), 'connections: {}\n', 'utf-8');
const testIo = makeIo();
const entryChoices = ['setup', 'exit'];
@ -1254,7 +1246,7 @@ describe('setup status', () => {
it('runs sources after database setup', async () => {
const calls: string[] = [];
const io = makeIo();
await writeFile(join(tempDir, 'ktx.yaml'), ['project: revenue', 'connections: {}', ''].join('\n'), 'utf-8');
await writeFile(join(tempDir, 'ktx.yaml'), ['connections: {}', ''].join('\n'), 'utf-8');
await expect(
runKtxSetup(
@ -1315,7 +1307,6 @@ describe('setup status', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: revenue',
'setup:',
' database_connection_ids:',
' - warehouse',
@ -1374,7 +1365,6 @@ describe('setup status', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: revenue',
'connections:',
' warehouse:',
' driver: postgres',
@ -1430,7 +1420,7 @@ describe('setup status', () => {
it('runs context after sources and before agents in full setup', async () => {
const calls: string[] = [];
const io = makeIo();
await writeFile(join(tempDir, 'ktx.yaml'), ['project: revenue', 'connections: {}', ''].join('\n'), 'utf-8');
await writeFile(join(tempDir, 'ktx.yaml'), ['connections: {}', ''].join('\n'), 'utf-8');
await expect(
runKtxSetup(
@ -1543,7 +1533,7 @@ describe('setup status', () => {
it('runs agent setup after context succeeds in --agents mode', async () => {
const calls: string[] = [];
const io = makeIo();
await writeFile(join(tempDir, 'ktx.yaml'), ['project: revenue', 'connections: {}', ''].join('\n'), 'utf-8');
await writeFile(join(tempDir, 'ktx.yaml'), ['connections: {}', ''].join('\n'), 'utf-8');
await expect(
runKtxSetup(
@ -1596,7 +1586,7 @@ describe('setup status', () => {
projectDir: tempDir,
installs: [{ target: 'codex' as const, scope: 'project' as const, mode: 'cli' as const }],
}));
await writeFile(join(tempDir, 'ktx.yaml'), ['project: revenue', 'connections: {}', ''].join('\n'), 'utf-8');
await writeFile(join(tempDir, 'ktx.yaml'), ['connections: {}', ''].join('\n'), 'utf-8');
await expect(
runKtxSetup(
@ -1633,7 +1623,6 @@ describe('setup status', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: revenue',
'setup:',
' database_connection_ids:',
' - warehouse',
@ -1671,7 +1660,6 @@ describe('setup status', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: revenue',
'setup:',
' database_connection_ids: []',
'connections: {}',
@ -1778,7 +1766,6 @@ describe('setup status', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: revenue',
'setup:',
' database_connection_ids: []',
'connections: {}',

View file

@ -1,5 +1,5 @@
import { existsSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { basename, join, resolve } from 'node:path';
import { getLatestLocalIngestStatus, savedMemoryCountsForReport } from '@ktx/context/ingest';
import {
ktxLocalStateDbPath,
@ -317,7 +317,7 @@ export async function readKtxSetupStatus(projectDir: string): Promise<KtxSetupSt
})) ?? [];
return {
project: { path: resolvedProjectDir, ready: true, name: project.config.project },
project: { path: resolvedProjectDir, ready: true, name: basename(project.projectDir) || project.projectDir },
llm,
embeddings,
databases: databaseIds.map((connectionId) => ({

View file

@ -44,7 +44,7 @@ async function seedSlSource(input: {
sourceName?: string;
yaml?: string;
}): Promise<void> {
const project = await initKtxProject({ projectDir: input.projectDir, projectName: 'warehouse' });
const project = await initKtxProject({ projectDir: input.projectDir });
await project.fileStore.writeFile(
`semantic-layer/${input.connectionId ?? 'warehouse'}/${input.sourceName ?? 'orders'}.yaml`,
input.yaml ?? ORDERS_YAML,
@ -139,7 +139,7 @@ describe('runKtxSl', () => {
it('fails validation when a table-backed source declares columns absent from a matching warehouse manifest', async () => {
const projectDir = join(tempDir, 'project');
const project = await initKtxProject({ projectDir, projectName: 'warehouse' });
const project = await initKtxProject({ projectDir });
await project.fileStore.writeFile(
'semantic-layer/postgres-warehouse/_schema/orbit_analytics.yaml',
`tables:
@ -189,7 +189,7 @@ joins: []
it('runs sl query and prints SQL output', async () => {
const projectDir = join(tempDir, 'project');
const project = await initKtxProject({ projectDir, projectName: 'warehouse' });
const project = await initKtxProject({ projectDir });
project.config.connections.warehouse = { driver: 'postgres' };
await project.fileStore.writeFile(
'semantic-layer/warehouse/orders.yaml',
@ -246,7 +246,7 @@ joins: []
it('runs sl query from a JSON query file', async () => {
const projectDir = join(tempDir, 'project');
const project = await initKtxProject({ projectDir, projectName: 'warehouse' });
const project = await initKtxProject({ projectDir });
project.config.connections.warehouse = { driver: 'postgres' };
await project.fileStore.writeFile(
'semantic-layer/warehouse/orders.yaml',
@ -313,7 +313,7 @@ joins: []
it('creates default sl query compute through the managed runtime helper', async () => {
const projectDir = join(tempDir, 'project');
const project = await initKtxProject({ projectDir, projectName: 'warehouse' });
const project = await initKtxProject({ projectDir });
project.config.connections.warehouse = { driver: 'postgres' };
await project.fileStore.writeFile(
'semantic-layer/warehouse/orders.yaml',
@ -374,7 +374,7 @@ joins: []
it('executes sl query through the injected query executor', async () => {
const projectDir = join(tempDir, 'project');
const project = await initKtxProject({ projectDir, projectName: 'warehouse' });
const project = await initKtxProject({ projectDir });
project.config.connections.warehouse = { driver: 'postgres', url: 'postgres://example/db' };
await project.fileStore.writeFile(
'semantic-layer/warehouse/orders.yaml',
@ -459,7 +459,7 @@ joins: []
it('executes sl query against a local SQLite connection through the default executor', async () => {
const projectDir = join(tempDir, 'project');
const project = await initKtxProject({ projectDir, projectName: 'warehouse' });
const project = await initKtxProject({ projectDir });
const dbPath = join(projectDir, 'warehouse.db');
const db = new Database(dbPath);
db.exec(`
@ -475,7 +475,6 @@ joins: []
await writeFile(
join(projectDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: sqlite',

View file

@ -80,7 +80,6 @@ async function writeSqliteScanConfig(projectDir: string, dbPath: string, enrich
await writeFile(
join(projectDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: sqlite',
@ -195,7 +194,7 @@ describe('standalone built ktx CLI smoke', () => {
expectProjectStderr(connectionTest, projectDir);
expect(connectionTest.stdout).toContain('Connection test passed: warehouse');
expect(connectionTest.stdout).toContain('Driver: sqlite');
expect(connectionTest.stdout).toContain('Tables: 2');
expect(connectionTest.stdout).toContain('Status: ok');
const ingest = await runBuiltCli(['ingest', 'warehouse', '--project-dir', projectDir, '--fast', '--no-input']);
expectProjectStderr(ingest, projectDir);
@ -218,7 +217,6 @@ describe('standalone built ktx CLI smoke', () => {
await writeFile(
join(projectDir, 'ktx.yaml'),
[
'project: gateway-smoke',
'llm:',
' provider:',
' backend: gateway',

View file

@ -1,4 +1,6 @@
import { basename } from 'node:path';
import type {
KtxConfigIssue,
KtxLocalProject,
KtxProjectConfig,
KtxProjectConnectionConfig,
@ -56,6 +58,12 @@ interface StorageStatus {
gitAuthor: string;
}
interface ConfigStatus {
status: ProjectStatusLevel;
detail: string;
issues: KtxConfigIssue[];
}
interface WarningItem {
message: string;
fix?: string;
@ -72,6 +80,7 @@ function hasOwnField(value: Record<string, unknown>, key: string): boolean {
export interface ProjectStatus {
projectName: string;
projectDir: string;
config: ConfigStatus;
llm: LlmStatus;
embeddings: EmbeddingsStatus;
storage: StorageStatus;
@ -281,9 +290,9 @@ function buildConnectionStatus(
return warn('repoUrl not set', 'Rerun `ktx setup`');
}
case 'metabase': {
const url = (conn as Record<string, unknown>).url ?? (conn as Record<string, unknown>).base_url;
const url = (conn as Record<string, unknown>).api_url;
if (typeof url === 'string' && url.length > 0) return ok(`url: ${url}`);
return warn('url not set', 'Rerun `ktx setup`');
return warn('api_url not set', 'Rerun `ktx setup`');
}
case 'looker':
case 'lookml': {
@ -610,12 +619,26 @@ function buildVerdict(
export interface BuildProjectStatusOptions {
env?: NodeJS.ProcessEnv;
postgresQueryHistoryProbe?: PostgresQueryHistoryProbe;
configIssues?: KtxConfigIssue[];
}
function buildConfigStatus(issues: KtxConfigIssue[] | undefined): ConfigStatus {
const list = issues ?? [];
if (list.length === 0) {
return { status: 'ok', detail: 'ktx.yaml schema valid', issues: [] };
}
return {
status: 'warn',
detail: `${list.length} issue${list.length === 1 ? '' : 's'} in ktx.yaml`,
issues: list,
};
}
export async function buildProjectStatus(project: KtxLocalProject, options: BuildProjectStatusOptions = {}): Promise<ProjectStatus> {
const env = options.env ?? process.env;
const config = project.config;
const configStatus = buildConfigStatus(options.configIssues);
const llm = buildLlmStatus(config.llm, env);
const embeddings = buildEmbeddingsStatus(config.ingest.embeddings, env);
const storage = buildStorageStatus(config);
@ -628,8 +651,9 @@ export async function buildProjectStatus(project: KtxLocalProject, options: Buil
const { verdict, reason, nextActions } = buildVerdict(llm, embeddings, connections, queryHistory, warnings);
return {
projectName: config.project,
projectName: basename(project.projectDir) || project.projectDir,
projectDir: project.projectDir,
config: configStatus,
llm,
embeddings,
storage,
@ -719,6 +743,13 @@ export function renderProjectStatus(status: ProjectStatus, options: RenderProjec
lines.push(` ${label('Embeddings')} ${embedDetail} ${sym(status.embeddings.status)} ${dim(status.embeddings.detail)}`);
lines.push(` ${label('Storage')} ${dim(`${status.storage.state} (state) · ${status.storage.search} (search)`)}`);
lines.push(` ${label('Config')} ${sym(status.config.status)} ${dim(status.config.detail)}`);
if (status.config.issues.length > 0) {
for (const issue of status.config.issues) {
lines.push(` ${color('warn', SYMBOL.warn)} ${issue.message}`);
if (issue.fix) lines.push(` ${dim(`${issue.fix}`)}`);
}
}
lines.push('');
// Connections