Merge remote-tracking branch 'origin/main' into fix-sl-query-source-column-type

# Conflicts:
#	packages/context/skills/metabase_ingest/SKILL.md
#	packages/context/skills/sl_capture/SKILL.md
This commit is contained in:
Andrey Avtomonov 2026-05-15 01:43:02 +02:00
commit cd49d5d4ae
168 changed files with 3567 additions and 1621 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

@ -20,7 +20,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;
}
@ -32,14 +32,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;
}
@ -58,6 +58,8 @@ type CommandPathNode = CommandWithGlobalOptions & {
const PROJECT_AWARE_ROOT_COMMANDS = new Set(['setup', 'connection', 'ingest', 'wiki', 'sl', 'status']);
const COMMANDS_THAT_CREATE_PROJECT = new Set(['setup', 'ktx dev init']);
const COMMANDS_WITH_OWN_MISSING_PROJECT_HANDLING = new Set(['status']);
const GLOBAL_OPTIONS_WITH_VALUE = new Set(['--project-dir']);
const GLOBAL_OPTIONS_WITHOUT_VALUE = new Set(['--debug', '--help', '-h', '--version', '-v']);
class KtxProjectMissingAbortError extends Error {
readonly isKtxProjectMissingAbort = true;
@ -72,24 +74,6 @@ function isKtxProjectMissingAbortError(error: unknown): error is KtxProjectMissi
(typeof error === 'object' && error !== null && (error as { isKtxProjectMissingAbort?: unknown }).isKtxProjectMissingAbort === true)
);
}
const REMOVED_COMMAND_PATHS = new Set([
'scan',
'wiki read',
'wiki write',
]);
const GLOBAL_OPTIONS_WITH_VALUE = new Set(['--project-dir']);
const OPTIONS_WITH_VALUE = new Set([
'--project-dir',
'--query-history-window-days',
'--user-id',
'--limit',
'--format',
'--connection-id',
'--source-name',
'--query-file',
'--max-rows',
]);
export interface CommandWithGlobalOptions {
opts: () => object;
optsWithGlobals?: () => object;
@ -336,43 +320,32 @@ function formatCliError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function commandPathFromArgv(argv: string[]): string[] {
const path: string[] = [];
for (let index = 0; index < argv.length && path.length < 2; index += 1) {
function firstTopLevelCommandToken(argv: string[]): string | null {
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === undefined) {
continue;
}
if (arg === '--') {
break;
return null;
}
if ((path.length === 0 ? GLOBAL_OPTIONS_WITH_VALUE : OPTIONS_WITH_VALUE).has(arg)) {
if (GLOBAL_OPTIONS_WITH_VALUE.has(arg)) {
index += 1;
continue;
}
const optionsWithValue = path.length === 0 ? GLOBAL_OPTIONS_WITH_VALUE : OPTIONS_WITH_VALUE;
if ([...optionsWithValue].some((option) => arg.startsWith(`${option}=`))) {
if ([...GLOBAL_OPTIONS_WITH_VALUE].some((option) => arg.startsWith(`${option}=`))) {
continue;
}
if (path.length === 0 && arg === '--debug') {
if (GLOBAL_OPTIONS_WITHOUT_VALUE.has(arg) || arg.startsWith('-')) {
continue;
}
if (arg.startsWith('-')) {
continue;
}
path.push(arg);
return arg;
}
return path;
return null;
}
function removedCommandName(argv: string[]): string | null {
const path = commandPathFromArgv(argv);
if (path.length === 0) {
return null;
}
const pathKey = path.join(' ');
return REMOVED_COMMAND_PATHS.has(pathKey) ? path.at(-1) ?? null : null;
function isKnownTopLevelCommand(program: Command, commandName: string): boolean {
return program.commands.some((command) => command.name() === commandName || command.aliases().includes(commandName));
}
async function runBareInteractiveCommand(
@ -489,9 +462,9 @@ export async function runCommanderKtxCli(
return 0;
}
const removedCommand = removedCommandName(argv);
if (removedCommand) {
io.stderr.write(`error: unknown command '${removedCommand}'\n`);
const topLevelCommand = firstTopLevelCommandToken(argv);
if (topLevelCommand && !isKnownTopLevelCommand(program, topLevelCommand)) {
io.stderr.write(`error: unknown command '${topLevelCommand}'\n`);
return 1;
}

View file

@ -59,14 +59,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,
});
@ -77,7 +73,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

@ -82,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' },
@ -100,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);
@ -111,7 +111,7 @@ describe('runKtxConnection', () => {
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' },
});
@ -136,7 +136,7 @@ describe('runKtxConnection', () => {
it('reports the connector error and still cleans up when native testConnection fails', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir, projectName: 'warehouse' });
await initKtxProject({ projectDir });
await writeConnections(projectDir, {
warehouse: { driver: 'sqlite' },
});
@ -155,7 +155,7 @@ describe('runKtxConnection', () => {
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',
@ -201,7 +201,7 @@ describe('runKtxConnection', () => {
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, {
bi_looker: {
driver: 'looker',
@ -230,7 +230,7 @@ describe('runKtxConnection', () => {
it('falls back to userId when Looker metadata has no display name', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir, projectName: 'warehouse' });
await initKtxProject({ projectDir });
await writeConnections(projectDir, {
bi_looker: {
driver: 'looker',
@ -255,7 +255,7 @@ describe('runKtxConnection', () => {
it('reports the Looker error when testConnection fails', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir, projectName: 'warehouse' });
await initKtxProject({ projectDir });
await writeConnections(projectDir, {
bi_looker: {
driver: 'looker',
@ -277,7 +277,7 @@ describe('runKtxConnection', () => {
it('tests a Notion connection by retrieving the bot user', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir, projectName: 'warehouse' });
await initKtxProject({ projectDir });
await writeConnections(projectDir, {
docs: {
driver: 'notion',
@ -302,7 +302,7 @@ describe('runKtxConnection', () => {
it('falls back to bot id when Notion bot has no name', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir, projectName: 'warehouse' });
await initKtxProject({ projectDir });
await writeConnections(projectDir, {
docs: {
driver: 'notion',
@ -323,7 +323,7 @@ describe('runKtxConnection', () => {
it('tests a dbt connection via testRepoConnection (success)', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir, projectName: 'warehouse' });
await initKtxProject({ projectDir });
process.env.DBT_TOKEN = 'gh_token_abc'; // pragma: allowlist secret
await writeConnections(projectDir, {
'dbt-main': {
@ -354,7 +354,7 @@ describe('runKtxConnection', () => {
it('reports the git error when testRepoConnection fails for dbt', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir, projectName: 'warehouse' });
await initKtxProject({ projectDir });
await writeConnections(projectDir, {
'dbt-main': {
driver: 'dbt',
@ -377,7 +377,7 @@ describe('runKtxConnection', () => {
it('tests a LookML connection via testRepoConnection with camelCase repoUrl', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir, projectName: 'warehouse' });
await initKtxProject({ projectDir });
await writeConnections(projectDir, {
lookml_main: {
driver: 'lookml',
@ -400,7 +400,7 @@ describe('runKtxConnection', () => {
it('tests a MetricFlow connection via the nested metricflow block', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir, projectName: 'warehouse' });
await initKtxProject({ projectDir });
await writeConnections(projectDir, {
mf_main: {
driver: 'metricflow',
@ -422,7 +422,7 @@ describe('runKtxConnection', () => {
it('--all: prints a single coherent list with one row per connection', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir, projectName: 'warehouse' });
await initKtxProject({ projectDir });
await writeConnections(projectDir, {
warehouse: { driver: 'sqlite' },
docs: { driver: 'notion', auth_token: 'secret_token', crawl_mode: 'all_accessible' }, // pragma: allowlist secret
@ -450,7 +450,7 @@ describe('runKtxConnection', () => {
it('--all: marks failing connections, keeps passing ones, and returns non-zero', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir, projectName: 'warehouse' });
await initKtxProject({ projectDir });
await writeConnections(projectDir, {
warehouse: { driver: 'sqlite' },
broken: { driver: 'sqlite' },
@ -476,7 +476,7 @@ describe('runKtxConnection', () => {
it('--all: shows an empty-state message when no connections are configured', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir, projectName: 'warehouse' });
await initKtxProject({ projectDir });
const io = makeIo();
await expect(runKtxConnection({ command: 'test-all', projectDir }, io.io)).resolves.toBe(0);
@ -488,16 +488,18 @@ describe('runKtxConnection', () => {
it('rejects unknown drivers with a helpful error', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir, projectName: 'warehouse' });
await writeConnections(projectDir, {
mystery: { driver: 'duckdb' },
});
await initKtxProject({ projectDir });
await writeFile(
join(projectDir, 'ktx.yaml'),
'connections:\n mystery:\n driver: duckdb\n',
'utf-8',
);
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:');
expect(io.stderr()).toContain('connections.mystery.driver');
expect(io.stderr()).toContain('postgres');
});
});

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,

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,
@ -64,6 +64,11 @@ describe('formatDoctorReport', () => {
expect(output).toContain('Node 22+ · pnpm 10.20+');
expect(output).not.toContain('v22.16.0');
expect(output).toContain('Everything ready.');
expect(output).toContain('ktx status --json');
expect(output).toContain('ktx sl list');
expect(output).toContain('ktx wiki list');
expect(output).not.toContain('ktx scan');
expect(output).not.toContain('ktx sl ask');
});
it('shows the underlying detail for a single-check group on the group line', () => {
@ -328,7 +333,6 @@ describe('runKtxDoctor', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'storrage:',
' state: sqlite',
'ingest:',
@ -359,7 +363,7 @@ describe('runKtxDoctor', () => {
it('emits structured JSON when ktx.yaml fails Zod validation', async () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
['project: warehouse', 'storrage: {}', ''].join('\n'),
['storrage: {}', ''].join('\n'),
'utf-8',
);
const testIo = makeIo();
@ -387,7 +391,6 @@ describe('runKtxDoctor', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: sqlite',
@ -418,7 +421,6 @@ describe('runKtxDoctor', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: sqlite',
@ -452,7 +454,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');
@ -465,10 +467,10 @@ describe('runKtxDoctor', () => {
it('includes Postgres query-history readiness in project doctor output', async () => {
process.env.ANTHROPIC_API_KEY = 'test-key'; // pragma: allowlist secret
process.env.OPENAI_API_KEY = 'test-key'; // pragma: allowlist secret
process.env.WAREHOUSE_DATABASE_URL = 'postgresql://reader@example.test/warehouse';
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: postgres',
@ -520,15 +522,20 @@ describe('runKtxDoctor', () => {
expect(out).toContain('pg_stat_statements ready (PostgreSQL 16.4)');
expect(out).toContain('info: pg_stat_statements.max is 1000');
expect(out).not.toContain('Update the Postgres parameter group or config');
expect(out).toContain('ktx status --json');
expect(out).toContain('ktx sl list');
expect(out).toContain('ktx wiki list');
expect(out).not.toContain('ktx scan');
expect(out).not.toContain('ktx sl ask');
delete process.env.ANTHROPIC_API_KEY;
delete process.env.OPENAI_API_KEY;
delete process.env.WAREHOUSE_DATABASE_URL;
});
it('returns blocked verdict when LLM is not configured', async () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: sqlite',
@ -548,6 +555,7 @@ describe('runKtxDoctor', () => {
).resolves.toBe(1);
expect(testIo.stdout()).toContain('no LLM configured');
expect(testIo.stdout()).not.toContain('ktx ask');
expect(testIo.stdout()).toContain('ktx setup');
});
@ -558,7 +566,6 @@ describe('runKtxDoctor', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: postgres',
@ -620,7 +627,6 @@ describe('runKtxDoctor', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: sqlite',
@ -660,7 +666,6 @@ describe('runKtxDoctor', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: sqlite',
@ -695,7 +700,6 @@ describe('runKtxDoctor', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: sqlite',
@ -724,7 +728,6 @@ describe('runKtxDoctor', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'storrage:',
' state: sqlite',
'ingest:',
@ -752,7 +755,7 @@ describe('runKtxDoctor', () => {
it('emits structured JSON issues when validation fails', async () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
['project: warehouse', 'storrage: {}', ''].join('\n'),
['storrage: {}', ''].join('\n'),
'utf-8',
);
const testIo = makeIo();
@ -788,7 +791,6 @@ describe('runKtxDoctor', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: postgres',

View file

@ -5,6 +5,7 @@ import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
import type { KtxConfigIssue } from '@ktx/context/project';
import { KTX_NEXT_STEP_DIRECT_COMMANDS } from './next-steps.js';
import type { BuildProjectStatusOptions } from './status-project.js';
const execFileAsync = promisify(execFile);
@ -287,7 +288,7 @@ interface RenderOptions {
command?: 'setup' | 'project';
}
const NEXT_STEPS_PROJECT = ['ktx scan', 'ktx wiki', 'ktx sl ask "…"'];
const NEXT_STEPS_PROJECT = KTX_NEXT_STEP_DIRECT_COMMANDS.map((step) => step.command);
export function formatDoctorReport(report: DoctorReport, options: Partial<RenderOptions> = {}): string {
const opts: RenderOptions = {

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 () => {
@ -502,7 +502,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'],
@ -580,7 +580,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);
@ -1482,7 +1482,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(

View file

@ -106,10 +106,10 @@ export async function writeWarehouseConfig(projectDir: string): Promise<void> {
await writeFile(
join(projectDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' prod-metabase:',
' driver: metabase',
' api_url: https://metabase.example.test',
' warehouse_a:',
' driver: postgres',
'ingest:',
@ -126,7 +126,6 @@ export async function writeMetabaseConfig(projectDir: string): Promise<void> {
await writeFile(
join(projectDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: postgres',
@ -488,7 +487,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

@ -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

@ -43,13 +43,13 @@ export interface PrintListArgs<Row> {
io: KtxCliIo;
}
export interface KtxJsonResultEnvelope<T> {
interface KtxJsonResultEnvelope<T> {
kind: string;
data: T;
meta?: Record<string, unknown>;
}
export function writeJsonResult<T>(io: KtxCliIo, envelope: KtxJsonResultEnvelope<T>): void {
function writeJsonResult<T>(io: KtxCliIo, envelope: KtxJsonResultEnvelope<T>): void {
io.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`);
}

View file

@ -1,8 +1,9 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { initKtxProject } from '@ktx/context/project';
import { initKtxProject, loadKtxProject } from '@ktx/context/project';
import type { KtxEmbeddingPort } from '@ktx/context';
import { writeLocalKnowledgePage } from '@ktx/context/wiki';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { runKtxKnowledge } from './knowledge.js';
@ -40,6 +41,28 @@ class FakeEmbeddingPort implements KtxEmbeddingPort {
}
}
interface WikiPageFixture {
key?: string;
summary?: string;
content?: string;
tags?: string[];
slRefs?: string[];
}
async function seedWikiPage(projectDir: string, fixture: WikiPageFixture = {}): Promise<void> {
const project = await loadKtxProject({ projectDir });
await writeLocalKnowledgePage(project, {
key: fixture.key ?? 'metrics-revenue',
scope: 'GLOBAL',
userId: 'local',
summary: fixture.summary ?? 'Revenue',
content: fixture.content ?? 'Revenue is paid order value.',
tags: fixture.tags ?? ['finance'],
refs: [],
slRefs: fixture.slRefs ?? ['orders'],
});
}
describe('runKtxKnowledge', () => {
let tempDir: string;
@ -51,36 +74,10 @@ describe('runKtxKnowledge', () => {
await rm(tempDir, { recursive: true, force: true });
});
it('writes, reads, lists, and searches wiki pages', async () => {
it('lists and searches wiki pages', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir, projectName: 'warehouse' });
const writeIo = makeIo();
await expect(
runKtxKnowledge(
{
command: 'write',
projectDir,
key: 'metrics-revenue',
scope: 'GLOBAL',
userId: 'local',
summary: 'Revenue',
content: 'Revenue is paid order value.',
tags: ['finance'],
refs: [],
slRefs: ['orders'],
},
writeIo.io,
),
).resolves.toBe(0);
expect(writeIo.stdout()).toContain('Wrote wiki/global/metrics-revenue.md');
const readIo = makeIo();
await expect(
runKtxKnowledge({ command: 'read', projectDir, key: 'metrics-revenue', userId: 'local' }, readIo.io),
).resolves.toBe(0);
expect(readIo.stdout()).toContain('# metrics-revenue');
expect(readIo.stdout()).toContain('Revenue is paid order value.');
await initKtxProject({ projectDir });
await seedWikiPage(projectDir);
const listIo = makeIo();
await expect(runKtxKnowledge({ command: 'list', projectDir, userId: 'local' }, listIo.io)).resolves.toBe(0);
@ -93,27 +90,10 @@ describe('runKtxKnowledge', () => {
expect(searchIo.stdout()).toContain('metrics-revenue');
});
it('prints wiki list, search, and read as public JSON envelopes', async () => {
it('prints wiki list and search as public JSON envelopes', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir, projectName: 'warehouse' });
await expect(
runKtxKnowledge(
{
command: 'write',
projectDir,
key: 'metrics-revenue',
scope: 'GLOBAL',
userId: 'local',
summary: 'Revenue',
content: 'Revenue is paid order value.',
tags: ['finance'],
refs: [],
slRefs: ['orders'],
},
makeIo().io,
),
).resolves.toBe(0);
await initKtxProject({ projectDir });
await seedWikiPage(projectDir);
const listIo = makeIo();
await expect(runKtxKnowledge({ command: 'list', projectDir, userId: 'local', json: true }, listIo.io)).resolves.toBe(
@ -137,53 +117,11 @@ describe('runKtxKnowledge', () => {
data: { items: [expect.objectContaining({ key: 'metrics-revenue', summary: 'Revenue' })] },
meta: { command: 'wiki search' },
});
const readIo = makeIo();
await expect(
runKtxKnowledge({ command: 'read', projectDir, key: 'metrics-revenue', userId: 'local', json: true }, readIo.io),
).resolves.toBe(0);
expect(JSON.parse(readIo.stdout())).toMatchObject({
kind: 'wiki.page',
data: {
key: 'metrics-revenue',
summary: 'Revenue',
content: 'Revenue is paid order value.',
},
});
});
it('rejects slash-delimited write keys with a flat-key suggestion', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir, projectName: 'warehouse' });
const writeIo = makeIo();
await expect(
runKtxKnowledge(
{
command: 'write',
projectDir,
key: 'orbit/company-overview',
scope: 'GLOBAL',
userId: 'local',
summary: 'Orbit',
content: 'Orbit overview.',
tags: [],
refs: [],
slRefs: [],
},
writeIo.io,
),
).resolves.toBe(1);
expect(writeIo.stderr()).toContain(
'Invalid wiki key "orbit/company-overview". Wiki keys must be flat; use "orbit-company-overview".',
);
expect(writeIo.stdout()).toBe('');
});
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,25 +135,14 @@ describe('runKtxKnowledge', () => {
it('uses configured embeddings for semantic wiki search', async () => {
const projectDir = join(tempDir, 'semantic-project');
await initKtxProject({ projectDir, projectName: 'warehouse' });
await expect(
runKtxKnowledge(
{
command: 'write',
projectDir,
key: 'active-contract-arr-open-tickets',
scope: 'GLOBAL',
userId: 'local',
summary: 'Active Contract ARR Ranked by Open Support Ticket Count',
content: 'Accounts ranked by annual recurring contract value and support ticket load.',
tags: ['historic-sql'],
refs: [],
slRefs: [],
},
makeIo().io,
),
).resolves.toBe(0);
await initKtxProject({ projectDir });
await seedWikiPage(projectDir, {
key: 'active-contract-arr-open-tickets',
summary: 'Active Contract ARR Ranked by Open Support Ticket Count',
content: 'Accounts ranked by annual recurring contract value and support ticket load.',
tags: ['historic-sql'],
slRefs: [],
});
const searchIo = makeIo();
await expect(

View file

@ -5,20 +5,16 @@ import {
} from '@ktx/context';
import { loadKtxProject } from '@ktx/context/project';
import {
type LocalKnowledgeScope,
type LocalKnowledgeSearchResult,
type LocalKnowledgeSummary,
listLocalKnowledgePages,
readLocalKnowledgePage,
searchLocalKnowledgePages,
writeLocalKnowledgePage,
} from '@ktx/context/wiki';
import { resolveOutputMode } from './io/mode.js';
import { printList, type PrintListColumn, writeJsonResult } from './io/print-list.js';
import { printList, type PrintListColumn } from './io/print-list.js';
export type KtxKnowledgeArgs =
| { command: 'list'; projectDir: string; userId: string; output?: string; json?: boolean }
| { command: 'read'; projectDir: string; key: string; userId: string; json?: boolean }
| {
command: 'search';
projectDir: string;
@ -27,18 +23,6 @@ export type KtxKnowledgeArgs =
output?: string;
json?: boolean;
limit?: number;
}
| {
command: 'write';
projectDir: string;
key: string;
scope: LocalKnowledgeScope;
userId: string;
summary: string;
content: string;
tags: string[];
refs: string[];
slRefs: string[];
};
type KtxKnowledgeIo = import('./cli-runtime.js').KtxCliIo;
@ -104,25 +88,6 @@ export async function runKtxKnowledge(
});
return 0;
}
if (args.command === 'read') {
const page = await readLocalKnowledgePage(project, { key: args.key, userId: args.userId });
if (!page) {
throw new Error(`Wiki page "${args.key}" was not found`);
}
if (args.json) {
writeJsonResult(io, {
kind: 'wiki.page',
data: page,
meta: { command: 'wiki read' },
});
return 0;
}
io.stdout.write(`# ${page.key}\n\n`);
io.stdout.write(`Scope: ${page.scope}\n`);
io.stdout.write(`Summary: ${page.summary}\n\n`);
io.stdout.write(`${page.content}\n`);
return 0;
}
if (args.command === 'search') {
const results = await searchLocalKnowledgePages(project, {
query: args.query,
@ -153,18 +118,6 @@ export async function runKtxKnowledge(
});
return 0;
}
const write = await writeLocalKnowledgePage(project, {
key: args.key,
scope: args.scope,
userId: args.userId,
summary: args.summary,
content: args.content,
tags: args.tags,
refs: args.refs,
slRefs: args.slRefs,
});
io.stdout.write(`Wrote ${write.path}\n`);
return 0;
} catch (error) {
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);

View file

@ -40,7 +40,6 @@ describe('CLI local ingest adapters', () => {
await writeProject(
tempDir,
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: postgres',
@ -71,7 +70,6 @@ describe('CLI local ingest adapters', () => {
await writeProject(
tempDir,
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: postgres',
@ -103,7 +101,6 @@ describe('CLI local ingest adapters', () => {
await writeProject(
tempDir,
[
'project: warehouse',
'connections:',
' bq:',
' driver: bigquery',
@ -136,7 +133,6 @@ describe('CLI local ingest adapters', () => {
await writeProject(
tempDir,
[
'project: warehouse',
'connections:',
' sf:',
' driver: snowflake',
@ -172,7 +168,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',
@ -94,12 +92,11 @@ describe('createKtxCliScanConnector', () => {
expect(bigQueryMock.constructorInputs[0]).not.toHaveProperty('maxBytesBilled');
});
it('throws for structural daemon-only fallback configs', async () => {
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
it('rejects daemon-only fallback driver configs at config parse time', async () => {
await initKtxProject({ projectDir: tempDir });
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: duckdb',
@ -108,19 +105,17 @@ describe('createKtxCliScanConnector', () => {
].join('\n'),
'utf-8',
);
const project = await loadKtxProject({ projectDir: tempDir });
await expect(createKtxCliScanConnector(project, 'warehouse')).rejects.toThrow(
'Connection "warehouse" uses driver "duckdb", which has no native standalone KTX scan connector',
await expect(loadKtxProject({ projectDir: tempDir })).rejects.toThrow(
/connections\.warehouse\.driver:.*Invalid discriminator value/,
);
});
it('throws a clear error when the connection block has no driver field', async () => {
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
it('rejects connection blocks with no driver field at config parse time', async () => {
await initKtxProject({ projectDir: tempDir });
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' type: postgres',
@ -129,10 +124,9 @@ describe('createKtxCliScanConnector', () => {
].join('\n'),
'utf-8',
);
const project = await loadKtxProject({ projectDir: tempDir });
await expect(createKtxCliScanConnector(project, 'warehouse')).rejects.toThrow(
'Connection "warehouse" has no `driver` field in ktx.yaml',
await expect(loadKtxProject({ projectDir: tempDir })).rejects.toThrow(
/connections\.warehouse\.driver:.*Invalid discriminator value/,
);
});
});

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: {
@ -85,7 +85,7 @@ describe('buildPublicIngestPlan', () => {
it('plans warehouse connections as scan targets and source connections as source ingest targets', () => {
const project = projectWithConnections({
warehouse: { driver: 'postgres' },
prod_metabase: { driver: 'metabase' },
prod_metabase: { driver: 'metabase', api_url: 'https://metabase.example.com' },
docs: { driver: 'notion' },
});
@ -745,7 +745,7 @@ describe('runKtxPublicIngest', () => {
const io = makeIo();
const project = projectWithConnections({
warehouse: { driver: 'postgres' },
prod_metabase: { driver: 'metabase' },
prod_metabase: { driver: 'metabase', api_url: 'https://metabase.example.com' },
});
const runScan = vi.fn(async () => 1);
const runIngest = vi.fn(async () => 0);

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',
@ -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 () => {
@ -1024,6 +1024,8 @@ describe('setup sources step', () => {
databaseMappings: { '1': 'warehouse' },
syncEnabled: { '1': true },
syncMode: 'ALL',
selections: { collections: [], items: [] },
defaultTagNames: [],
},
},
deps: {
@ -1181,6 +1183,8 @@ describe('setup sources step', () => {
databaseMappings: { '1': 'warehouse' },
syncEnabled: { '1': true },
syncMode: 'ALL',
selections: { collections: [], items: [] },
defaultTagNames: [],
},
});
const testPrompts = prompts({

View file

@ -451,6 +451,8 @@ function buildMetabaseConnection(args: KtxSetupSourcesArgs): KtxProjectConnectio
databaseMappings: { [String(args.metabaseDatabaseId)]: args.sourceWarehouseConnectionId },
syncEnabled: { [String(args.metabaseDatabaseId)]: true },
syncMode: 'ALL',
selections: { collections: [], items: [] },
defaultTagNames: [],
},
};
}

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',
@ -319,7 +311,7 @@ describe('setup status', () => {
' url: env:DATABASE_URL',
' metabase:',
' driver: metabase',
' url: env:METABASE_URL',
' api_url: https://metabase.example.test',
' api_key_ref: env:METABASE_API_KEY',
' warehouse_connection_id: warehouse',
'llm:',
@ -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',
@ -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,3 +1,4 @@
import { basename } from 'node:path';
import type {
KtxConfigIssue,
KtxLocalProject,
@ -8,6 +9,7 @@ import type {
} from '@ktx/context/project';
import type { PostgresPgssProbeResult } from '@ktx/context/ingest';
import type { DoctorCheck } from './doctor.js';
import { KTX_NEXT_STEP_DIRECT_COMMANDS } from './next-steps.js';
type ProjectStatusLevel = 'ok' | 'warn' | 'fail';
type ProjectVerdict = 'ready' | 'partial' | 'blocked';
@ -68,6 +70,8 @@ interface WarningItem {
fix?: string;
}
const PROJECT_READY_COMMANDS = KTX_NEXT_STEP_DIRECT_COMMANDS.map((step) => step.command);
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
@ -131,7 +135,7 @@ function buildLlmStatus(config: KtxProjectLlmConfig, env: NodeJS.ProcessEnv): Ll
backend,
model,
status: 'fail',
detail: 'no LLM configured — ktx ask will not work',
detail: 'no LLM configured; research agent will not run',
fix: 'Run: ktx setup (choose an LLM provider)',
};
}
@ -570,7 +574,7 @@ function buildVerdict(
if (llm.status === 'fail') {
return {
verdict: 'blocked',
reason: 'LLM not configured — `ktx ask` will not work.',
reason: 'LLM not configured; research agent will not run.',
nextActions: ['ktx setup'],
};
}
@ -604,7 +608,7 @@ function buildVerdict(
return {
verdict: 'ready',
reason: 'Ready.',
nextActions: ['ktx scan', 'ktx wiki', 'ktx sl ask "…"'],
nextActions: [...PROJECT_READY_COMMANDS],
};
}
@ -650,7 +654,7 @@ 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,