feat(cli)!: remove ktx agent command (#58)

* feat(cli)!: remove ktx agent command

* test(context): update PGlite boundary guardrail
This commit is contained in:
Andrey Avtomonov 2026-05-13 13:01:56 +02:00 committed by GitHub
parent eaaabb361e
commit 721f1a998f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 500 additions and 1895 deletions

View file

@ -47,7 +47,7 @@ export function TerminalPreview() {
<div className="h-2" />
<div>
<span className="term-prompt">$</span>{" "}
<span className="term-cmd">ktx agent context --json</span>
<span className="term-cmd">ktx status --json</span>
<span className="term-cursor ml-1" />
</div>
</div>

View file

@ -22,7 +22,7 @@ Agents should start with the smallest source that answers the task:
| How to check project readiness | [ktx status](/docs/cli-reference/ktx-status) | [Quickstart](/docs/getting-started/quickstart) |
| How context gets built | [Building Context](/docs/guides/building-context) | [ktx ingest](/docs/cli-reference/ktx-ingest) |
| How semantic YAML works | [Writing Context](/docs/guides/writing-context) | [ktx sl](/docs/cli-reference/ktx-sl) |
| How machine-readable CLI output is shaped | [ktx agent](/docs/cli-reference/ktx-agent) | [Markdown Access](/docs/ai-resources/markdown-access) |
| How machine-readable CLI output is shaped | [ktx sl](/docs/cli-reference/ktx-sl) | [ktx wiki](/docs/cli-reference/ktx-wiki) |
## Operating workflow

View file

@ -31,7 +31,8 @@ Every docs page has a Markdown route:
```text
https://docs.kaelio.com/ktx/docs/getting-started/quickstart.md
https://docs.kaelio.com/ktx/docs/cli-reference/ktx-agent.md
https://docs.kaelio.com/ktx/docs/cli-reference/ktx-sl.md
https://docs.kaelio.com/ktx/docs/cli-reference/ktx-wiki.md
https://docs.kaelio.com/ktx/docs/guides/building-context.md
```

View file

@ -1,148 +0,0 @@
---
title: "ktx agent"
description: "Machine-readable commands for coding agents."
---
Hidden commands that provide machine-readable JSON output for coding agents. These are the commands that agent integrations (Claude Code, Cursor, Codex, OpenCode) call under the hood — you typically won't use them directly.
All `ktx agent` subcommands require `--json` and produce structured JSON output on stdout.
## Command signature
```bash
ktx agent <subcommand> --json [options]
```
## Subcommands
| Subcommand | Description |
|-----------|-------------|
| `tools` | Print available agent-facing KTX tools |
| `context` | Print project context for agent planning |
| `sl list` | List semantic-layer sources |
| `sl read <sourceName>` | Read one semantic-layer source |
| `sl query` | Run a semantic-layer query from a JSON file |
| `wiki search <query>` | Search KTX wiki pages |
| `wiki read <pageId>` | Read one KTX wiki page |
| `sql execute` | Execute read-only SQL with a row limit |
## Options
### `agent tools`
| Flag | Description | Default |
|------|-------------|---------|
| `--json` | Print JSON output (required) | — |
### `agent context`
| Flag | Description | Default |
|------|-------------|---------|
| `--json` | Print JSON output (required) | — |
### `agent sl list`
| Flag | Description | Default |
|------|-------------|---------|
| `--json` | Print JSON output (required) | — |
| `--connection-id <id>` | Filter by connection id | — |
| `--query <text>` | Search source names and descriptions | — |
### `agent sl read`
| Flag | Description | Default |
|------|-------------|---------|
| `--json` | Print JSON output (required) | — |
| `--connection-id <id>` | Connection id containing the source | — |
### `agent sl query`
| Flag | Description | Default |
|------|-------------|---------|
| `--json` | Print JSON output (required) | — |
| `--connection-id <id>` | Connection id for execution (required) | — |
| `--query-file <path>` | JSON semantic-layer query file (required) | — |
| `--execute` | Execute the compiled query against the connection | `false` |
| `--max-rows <number>` | Maximum rows to return when executing (1-1000) | — |
### `agent wiki search`
| Flag | Description | Default |
|------|-------------|---------|
| `--json` | Print JSON output (required) | — |
| `--limit <number>` | Maximum search results | `10` |
### `agent wiki read`
| Flag | Description | Default |
|------|-------------|---------|
| `--json` | Print JSON output (required) | — |
### `agent sql execute`
| Flag | Description | Default |
|------|-------------|---------|
| `--json` | Print JSON output (required) | — |
| `--connection-id <id>` | Connection id for execution (required) | — |
| `--sql-file <path>` | SQL file to execute (required) | — |
| `--max-rows <number>` | Maximum rows to return, 1-1000 (required) | — |
## Examples
```bash
# List available tools
ktx agent tools --json
# Get project context for planning
ktx agent context --json
# List semantic sources
ktx agent sl list --json
# Search semantic sources by name
ktx agent sl list --json --query "revenue"
# Read a semantic source
ktx agent sl read orders --json --connection-id my-warehouse
# Run a semantic-layer query from a file
ktx agent sl query --json \
--connection-id my-warehouse \
--query-file /tmp/query.json \
--execute \
--max-rows 100
# Search wiki pages
ktx agent wiki search "churn definition" --json
# Read a specific wiki page
ktx agent wiki read page-abc123 --json
# Execute read-only SQL
ktx agent sql execute --json \
--connection-id my-warehouse \
--sql-file /tmp/query.sql \
--max-rows 500
```
## Output
Every `ktx agent` command writes JSON to stdout and diagnostic text to stderr. Agents should parse stdout as JSON and treat a non-zero exit code as a failed tool call.
```json
{
"ok": true,
"data": {
"type": "agent-response"
}
}
```
## Common errors
| Error | Cause | Recovery |
|-------|-------|----------|
| Missing JSON output | `--json` was omitted | Re-run the same subcommand with `--json` |
| Unknown connection id | The requested connection is not configured in `ktx.yaml` | Call `ktx agent context --json` or `ktx connection list` to discover valid ids |
| Query file cannot be read | `--query-file` points to a missing or invalid JSON file | Write the query payload to a real file and pass its absolute path |
| SQL execution rejected | SQL is not read-only or `--max-rows` is missing | Use semantic-layer queries first; for direct SQL, pass read-only SQL and an explicit row limit |

View file

@ -28,6 +28,7 @@ ktx sl <subcommand> [options]
| Flag | Description | Default |
|------|-------------|---------|
| `--connection-id <id>` | Filter by KTX connection id | — |
| `--query <text>` | Search source names and descriptions | — |
| `--output <mode>` | Output mode: `pretty` (default in TTY), `plain` (TSV), or `json` | `pretty` |
| `--json` | Shortcut for `--output=json` (overrides `--output`) | `false` |
@ -36,6 +37,7 @@ ktx sl <subcommand> [options]
| Flag | Description | Default |
|------|-------------|---------|
| `--connection-id <id>` | KTX connection id (required) | — |
| `--json` | Print JSON output | `false` |
### `sl validate`
@ -55,6 +57,7 @@ ktx sl <subcommand> [options]
| Flag | Description | Default |
|------|-------------|---------|
| `--connection-id <id>` | KTX connection id | — |
| `--query-file <path>` | JSON semantic-layer query file | — |
| `--measure <measure>` | Measure to query; repeatable (at least one required) | — |
| `--dimension <dimension>` | Dimension to include; repeatable | — |
| `--filter <filter>` | Filter expression; repeatable | — |
@ -78,9 +81,15 @@ ktx sl list --connection-id my-warehouse
# List sources as JSON
ktx sl list --json
# Search sources as JSON
ktx sl list --json --query "revenue"
# Read a source definition
ktx sl read orders --connection-id my-warehouse
# Read a source definition as JSON
ktx sl read orders --connection-id my-warehouse --json
# Validate a source against the live schema
ktx sl validate orders --connection-id my-warehouse
@ -119,6 +128,13 @@ ktx sl query \
--dimension orders.created_date \
--execute \
--max-rows 1000
# Execute a query from a JSON file
ktx sl query \
--connection-id my-warehouse \
--query-file query.json \
--execute \
--max-rows 100
```
## Output

View file

@ -26,19 +26,23 @@ ktx wiki <subcommand> [options]
| Flag | Description | Default |
|------|-------------|---------|
| `--json` | Print JSON output | `false` |
| `--user-id <id>` | Local user id | `local` |
### `wiki read`
| Flag | Description | Default |
|------|-------------|---------|
| `--json` | Print JSON output | `false` |
| `--user-id <id>` | Local user id | `local` |
### `wiki search`
| Flag | Description | Default |
|------|-------------|---------|
| `--json` | Print JSON output | `false` |
| `--user-id <id>` | Local user id | `local` |
| `--limit <number>` | Maximum search results | — |
### `wiki write`
@ -58,12 +62,21 @@ ktx wiki <subcommand> [options]
# List all wiki pages
ktx wiki list
# List all wiki pages as JSON
ktx wiki list --json
# Read a specific wiki page
ktx wiki read revenue-definitions
# Read a specific wiki page as JSON
ktx wiki read revenue-definitions --json
# Search wiki pages
ktx wiki search "monthly recurring revenue"
# Search wiki pages as JSON
ktx wiki search "monthly recurring revenue" --json --limit 10
# Write a global knowledge page
ktx wiki write revenue-definitions \
--summary "Canonical revenue metric definitions" \
@ -97,13 +110,16 @@ Wiki commands print local knowledge pages and search results. Agents should sear
```json
{
"results": [
{
"key": "revenue-definitions",
"summary": "Canonical revenue metric definitions",
"score": 0.92
}
]
"kind": "list",
"data": {
"items": [
{
"key": "revenue-definitions",
"summary": "Canonical revenue metric definitions",
"score": 0.92
}
]
}
}
```

View file

@ -9,7 +9,6 @@
"ktx-sl",
"ktx-wiki",
"ktx-status",
"ktx-agent",
"ktx-dev"
]
}

View file

@ -211,7 +211,7 @@ KTX writes project state as plain files so agents can inspect and edit changes i
| `semantic-layer/<connection-id>/*.yaml` | context build, ingestion, or `ktx sl write` | Semantic source definitions agents use for SQL generation |
| `knowledge/global/*.md` | ingestion or `ktx wiki write --scope global` | Shared business context and metric definitions |
| `knowledge/user/<user-id>/*.md` | `ktx wiki write --scope user` | User-scoped notes for one agent/user context |
| `.claude/skills/ktx/SKILL.md`, `.agents/skills/ktx/SKILL.md` | CLI-mode agent integration setup | Agent instructions for calling `ktx agent` commands |
| `.claude/skills/ktx/SKILL.md`, `.agents/skills/ktx/SKILL.md` | CLI-mode agent integration setup | Agent instructions for calling public `ktx` commands |
## Verify it worked

View file

@ -3,37 +3,36 @@ title: Serving Agents
description: Expose your context to Claude Code, Cursor, Codex, and other coding agents.
---
Once you've built and refined your context, the final step is exposing it to
coding agents. KTX provides machine-readable CLI commands for direct terminal
access from Claude Code, Cursor, Codex, OpenCode, and custom agent workflows.
Once you've built and refined your context, expose it to coding agents through
the public KTX CLI. Claude Code, Cursor, Codex, OpenCode, and custom agent
workflows can call the same commands you use at a terminal.
## CLI Commands
KTX provides a set of machine-readable commands under `ktx agent`. These return
JSON output designed for programmatic consumption.
KTX public commands support JSON output for the context reads that agents use
most often. Use `--project-dir` when the agent is not already running inside the
KTX project directory.
### Available commands
```bash
# List available tools and their descriptions
ktx agent tools --json
# Get project context for planning
ktx agent context --json
# Check setup and context readiness
ktx status --json
```
**Semantic layer:**
```bash
# List sources
ktx agent sl list --json
ktx agent sl list --json --connection-id my-postgres
ktx sl list --json
ktx sl list --json --connection-id my-postgres
ktx sl list --json --query "revenue"
# Read a source
ktx agent sl read orders --json --connection-id my-postgres
ktx sl read orders --json --connection-id my-postgres
# Run a query from a JSON file
ktx agent sl query --json \
ktx sl query --json \
--connection-id my-postgres \
--query-file query.json \
--execute \
@ -44,20 +43,10 @@ ktx agent sl query --json \
```bash
# Search knowledge pages
ktx agent wiki search "revenue recognition" --json --limit 10
ktx wiki search "revenue recognition" --json --limit 10
# Read a specific page
ktx agent wiki read order-status-definitions --json
```
**SQL execution:**
```bash
# Execute read-only SQL with a row limit
ktx agent sql execute --json \
--connection-id my-postgres \
--sql-file query.sql \
--max-rows 500
ktx wiki read order-status-definitions --json
```
## Setting Up Your Agent

View file

@ -3,7 +3,9 @@ title: Agent Clients
description: Set up KTX with Claude Code, Cursor, Codex, and OpenCode.
---
KTX integrates with coding agents through CLI skills and command files. These files teach agents to call `ktx agent ...` commands directly from the terminal for semantic-layer context, wiki knowledge, and safe SQL execution.
KTX integrates with coding agents through CLI skills and command files. These
files teach agents to call public `ktx` commands directly from the terminal for
semantic-layer context and wiki knowledge.
Run `ktx setup` and select your agent targets, or configure manually using the snippets below.
@ -26,17 +28,17 @@ Create `.claude/skills/ktx/SKILL.md`:
```markdown title=".claude/skills/ktx/SKILL.md"
---
name: ktx
description: Use local KTX semantic context, wiki knowledge, and safe SQL execution for this project.
description: Use local KTX semantic context and wiki knowledge for this project.
---
Available commands:
- `ktx agent context --json --project-dir /path/to/project`
- `ktx agent sl list --json --project-dir /path/to/project`
- `ktx agent sl read '<sourceName>' --json --project-dir /path/to/project`
- `ktx agent sl query --json --project-dir /path/to/project --connection-id '<id>' --query-file '<path>' --execute --max-rows 100`
- `ktx agent wiki search '<query>' --json --project-dir /path/to/project`
- `ktx agent wiki read '<pageId>' --json --project-dir /path/to/project`
- `ktx agent sql execute --json --project-dir /path/to/project --connection-id '<id>' --sql-file '<path>' --max-rows 100`
- `ktx status --json --project-dir /path/to/project`
- `ktx sl list --json --project-dir /path/to/project`
- `ktx sl list --json --project-dir /path/to/project --query '<text>'`
- `ktx sl read '<sourceName>' --json --project-dir /path/to/project --connection-id '<id>'`
- `ktx sl query --json --project-dir /path/to/project --connection-id '<id>' --query-file '<path>' --execute --max-rows 100`
- `ktx wiki search '<query>' --json --project-dir /path/to/project --limit 10`
- `ktx wiki read '<pageId>' --json --project-dir /path/to/project`
```
### Workflow tips
@ -123,22 +125,19 @@ All supported agent clients call the same KTX CLI commands:
| Command | Description |
|---------|-------------|
| `ktx agent context --json` | Return a compact project context summary |
| `ktx agent tools --json` | List available agent-facing commands |
| `ktx agent wiki search <query> --json` | Search knowledge pages |
| `ktx agent wiki read <key> --json` | Read a knowledge page |
| `ktx agent wiki write --json` | Write or update a knowledge page |
| `ktx agent sl list --json` | List semantic layer sources |
| `ktx agent sl read <source> --json` | Read a semantic source definition |
| `ktx agent sl write --json` | Write or update a semantic source |
| `ktx agent sl validate --json` | Validate semantic source definitions |
| `ktx agent sl query --json` | Execute a semantic layer query when semantic compute is configured |
| `ktx agent sql execute --json` | Execute read-only SQL with an explicit row limit |
| `ktx status --json` | Return project setup and context readiness |
| `ktx wiki search <query> --json` | Search knowledge pages |
| `ktx wiki read <key> --json` | Read a knowledge page |
| `ktx wiki write <key>` | Write or update a knowledge page |
| `ktx sl list --json` | List semantic-layer sources |
| `ktx sl list --query <text> --json` | Search semantic-layer sources |
| `ktx sl read <source> --json --connection-id <id>` | Read a semantic source definition |
| `ktx sl write <source> --connection-id <id>` | Write or update a semantic source |
| `ktx sl validate <source> --connection-id <id>` | Validate semantic source definitions |
| `ktx sl query --json` | Execute a semantic-layer query when semantic compute is configured |
### Security constraints
- SQL execution is always read-only.
- Agent SQL execution requires an explicit `--max-rows` limit from 1 to 1000.
- Secrets and credentials are never exposed in command output.
- Commands resolve the project from `--project-dir`, `KTX_PROJECT_DIR`, or the nearest `ktx.yaml`.

View file

@ -511,4 +511,4 @@ No authentication required — SQLite is file-based. The file must be readable b
| Scan returns no tables | Schema/database/project filter is wrong or the user lacks metadata permissions | Verify the schema list and grant metadata read permissions |
| Historic SQL is empty | Query history extension or warehouse history view is unavailable | Enable the warehouse-specific history feature, then rerun scan or setup |
| Column statistics are missing | Connector cannot access stats tables or the warehouse does not expose them | Grant stats permissions where supported; otherwise rely on structural scan output |
| SQL execution fails through agents | Connection is missing, unreachable, or query execution is disabled | Run `ktx connection test <id>` and check the agent command flags |
| Semantic query execution fails | Connection is missing, unreachable, or query execution is disabled | Run `ktx connection test <id>` and check the `ktx sl query` flags |

View file

@ -67,12 +67,12 @@ ${link("/docs/guides/writing-context", "Writing Context", "Write semantic source
- [Full documentation](${absoluteUrl("/llms-full.txt")}): All docs pages in one plain-text markdown response
- [Markdown access guide](${absoluteUrl("/docs/ai-resources/markdown-access.md")}): How to fetch llms.txt, llms-full.txt, and per-page Markdown
- [Quickstart markdown](${absoluteUrl("/docs/getting-started/quickstart.md")}): Human setup walkthrough
- [Agent CLI markdown](${absoluteUrl("/docs/cli-reference/ktx-agent.md")}): Machine-readable agent commands
- [Semantic-layer CLI markdown](${absoluteUrl("/docs/cli-reference/ktx-sl.md")}): Semantic-layer commands and JSON output
- [Wiki CLI markdown](${absoluteUrl("/docs/cli-reference/ktx-wiki.md")}): Knowledge page commands and JSON output
## CLI Reference
${link("/docs/cli-reference/ktx-setup", "ktx setup", "Interactive project setup")}
${link("/docs/cli-reference/ktx-agent", "ktx agent", "Machine-readable commands for coding agents")}
${link("/docs/cli-reference/ktx-sl", "ktx sl", "Semantic-layer commands")}
${link("/docs/cli-reference/ktx-wiki", "ktx wiki", "Knowledge page commands")}
${link("/docs/cli-reference/ktx-connection", "ktx connection", "Connection management commands")}

View file

@ -1,152 +0,0 @@
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
KTX_AGENT_MAX_ROWS_CAP,
createKtxAgentRuntime,
parseAgentMaxRows,
readAgentJsonFile,
writeAgentJson,
writeAgentJsonError,
} from './agent-runtime.js';
function makeIo() {
let stdout = '';
let stderr = '';
return {
io: {
stdout: { write: (chunk: string) => (stdout += chunk) },
stderr: { write: (chunk: string) => (stderr += chunk) },
},
stdout: () => stdout,
stderr: () => stderr,
};
}
describe('agent runtime helpers', () => {
let tempDir: string;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'ktx-agent-runtime-'));
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
it('writes JSON success and error envelopes without color or spinners', () => {
const successIo = makeIo();
const errorIo = makeIo();
writeAgentJson(successIo.io, { ok: true });
writeAgentJsonError(errorIo.io, 'missing source', { code: 'NOT_FOUND' });
expect(JSON.parse(successIo.stdout())).toEqual({ ok: true });
expect(successIo.stderr()).toBe('');
expect(JSON.parse(errorIo.stderr())).toEqual({
ok: false,
error: { message: 'missing source', code: 'NOT_FOUND' },
});
expect(errorIo.stdout()).toBe('');
});
it('reads JSON query files as objects', async () => {
const path = join(tempDir, 'query.json');
await writeFile(path, '{"measures":["revenue"],"limit":50}', 'utf-8');
await expect(readAgentJsonFile(path)).resolves.toEqual({ measures: ['revenue'], limit: 50 });
});
it('rejects non-object JSON query files', async () => {
const path = join(tempDir, 'query.json');
await writeFile(path, '["revenue"]', 'utf-8');
await expect(readAgentJsonFile(path)).rejects.toThrow('must contain a JSON object');
});
it('requires positive row limits and enforces the agent cap', () => {
expect(parseAgentMaxRows(100)).toBe(100);
expect(() => parseAgentMaxRows(undefined)).toThrow('maxRows is required');
expect(() => parseAgentMaxRows(0)).toThrow('positive integer');
expect(() => parseAgentMaxRows(KTX_AGENT_MAX_ROWS_CAP + 1)).toThrow(String(KTX_AGENT_MAX_ROWS_CAP));
});
it('constructs local context ports with semantic compute and query executor', async () => {
const project = {
projectDir: tempDir,
configPath: join(tempDir, 'ktx.yaml'),
config: { project: 'revenue', connections: {} },
coreConfig: {},
git: {},
fileStore: {},
} as never;
const ports = { knowledge: {}, semanticLayer: {} } as never;
const semanticLayerCompute = { query: vi.fn(), validateSources: vi.fn(), generateSources: vi.fn() };
const queryExecutor = { execute: vi.fn() };
const loadProject = vi.fn(async () => project);
const createContextTools = vi.fn(() => ports);
await expect(
createKtxAgentRuntime(
{ projectDir: tempDir, enableSemanticCompute: true, enableQueryExecution: true },
{
loadProject,
createContextTools,
createSemanticLayerCompute: () => semanticLayerCompute,
createQueryExecutor: () => queryExecutor,
},
),
).resolves.toMatchObject({ project, ports, queryExecutor });
expect(loadProject).toHaveBeenCalledWith({ projectDir: tempDir });
expect(createContextTools).toHaveBeenCalledWith(project, {
semanticLayerCompute,
queryExecutor,
});
});
it('creates managed semantic compute when no test override is injected', async () => {
const project = {
projectDir: tempDir,
configPath: join(tempDir, 'ktx.yaml'),
config: { project: 'revenue', connections: {} },
coreConfig: {},
git: {},
fileStore: {},
} as never;
const ports = { semanticLayer: {} } as never;
const semanticLayerCompute = { query: vi.fn(), validateSources: vi.fn(), generateSources: vi.fn() };
const loadProject = vi.fn(async () => project);
const createContextTools = vi.fn(() => ports);
const createManagedSemanticLayerCompute = vi.fn(async () => semanticLayerCompute);
const { io } = makeIo();
await expect(
createKtxAgentRuntime(
{
projectDir: tempDir,
enableSemanticCompute: true,
enableQueryExecution: false,
cliVersion: '0.2.0',
runtimeInstallPolicy: 'auto',
io,
},
{
loadProject,
createContextTools,
createManagedSemanticLayerCompute,
},
),
).resolves.toMatchObject({ project, ports, semanticLayerCompute });
expect(createManagedSemanticLayerCompute).toHaveBeenCalledWith({
cliVersion: '0.2.0',
installPolicy: 'auto',
io,
});
expect(createContextTools).toHaveBeenCalledWith(project, {
semanticLayerCompute,
});
});
});

View file

@ -1,109 +0,0 @@
import { readFile } from 'node:fs/promises';
import { createDefaultLocalQueryExecutor, type KtxSqlQueryExecutorPort } from '@ktx/context/connections';
import type { KtxSemanticLayerComputePort } from '@ktx/context/daemon';
import { createLocalProjectMcpContextPorts, type KtxMcpContextPorts } from '@ktx/context/mcp';
import { type KtxLocalProject, loadKtxProject } from '@ktx/context/project';
import type { KtxCliIo } from './cli-runtime.js';
import {
createManagedPythonSemanticLayerComputePort,
type KtxManagedPythonInstallPolicy,
} from './managed-python-command.js';
export const KTX_AGENT_MAX_ROWS_CAP = 1000;
export interface KtxAgentRuntimeOptions {
projectDir: string;
enableSemanticCompute: boolean;
enableQueryExecution: boolean;
cliVersion?: string;
runtimeInstallPolicy?: KtxManagedPythonInstallPolicy;
io?: KtxCliIo;
}
export interface KtxAgentRuntime {
project: KtxLocalProject;
ports: KtxMcpContextPorts;
semanticLayerCompute?: KtxSemanticLayerComputePort;
queryExecutor?: KtxSqlQueryExecutorPort;
}
export interface KtxAgentRuntimeDeps {
loadProject?: typeof loadKtxProject;
createContextTools?: typeof createLocalProjectMcpContextPorts;
createSemanticLayerCompute?: () => KtxSemanticLayerComputePort;
createManagedSemanticLayerCompute?: typeof createManagedPythonSemanticLayerComputePort;
createQueryExecutor?: () => KtxSqlQueryExecutorPort;
}
export function writeAgentJson(io: KtxCliIo, value: unknown): void {
io.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
}
export function writeAgentJsonError(
io: KtxCliIo,
message: string,
detail: Record<string, unknown> = {},
): void {
io.stderr.write(`${JSON.stringify({ ok: false, error: { message, ...detail } }, null, 2)}\n`);
}
export async function readAgentJsonFile(path: string): Promise<Record<string, unknown>> {
const parsed = JSON.parse(await readFile(path, 'utf-8')) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error(`${path} must contain a JSON object.`);
}
return parsed as Record<string, unknown>;
}
export function parseAgentMaxRows(value: number | undefined): number {
if (!Number.isInteger(value) || value === undefined || value <= 0) {
throw new Error('maxRows is required and must be a positive integer.');
}
if (value > KTX_AGENT_MAX_ROWS_CAP) {
throw new Error(`maxRows must be less than or equal to ${KTX_AGENT_MAX_ROWS_CAP}.`);
}
return value;
}
async function createAgentSemanticLayerCompute(
options: KtxAgentRuntimeOptions,
deps: KtxAgentRuntimeDeps,
): Promise<KtxSemanticLayerComputePort | undefined> {
if (!options.enableSemanticCompute) {
return undefined;
}
if (deps.createSemanticLayerCompute) {
return deps.createSemanticLayerCompute();
}
if (!options.cliVersion || !options.runtimeInstallPolicy || !options.io) {
throw new Error('Managed Python semantic compute requires cliVersion, runtimeInstallPolicy, and io.');
}
const createManagedSemanticLayerCompute =
deps.createManagedSemanticLayerCompute ?? createManagedPythonSemanticLayerComputePort;
return createManagedSemanticLayerCompute({
cliVersion: options.cliVersion,
installPolicy: options.runtimeInstallPolicy,
io: options.io,
});
}
export async function createKtxAgentRuntime(
options: KtxAgentRuntimeOptions,
deps: KtxAgentRuntimeDeps = {},
): Promise<KtxAgentRuntime> {
const project = await (deps.loadProject ?? loadKtxProject)({ projectDir: options.projectDir });
const semanticLayerCompute = await createAgentSemanticLayerCompute(options, deps);
const queryExecutor = options.enableQueryExecution
? (deps.createQueryExecutor ?? createDefaultLocalQueryExecutor)()
: undefined;
const ports = (deps.createContextTools ?? createLocalProjectMcpContextPorts)(project, {
...(semanticLayerCompute ? { semanticLayerCompute } : {}),
...(queryExecutor ? { queryExecutor } : {}),
});
return {
project,
ports,
...(semanticLayerCompute ? { semanticLayerCompute } : {}),
...(queryExecutor ? { queryExecutor } : {}),
};
}

View file

@ -1,51 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
isMissingProjectConfigError,
missingConnectionSlSearchReadiness,
missingProjectSlSearchReadiness,
noConnectionsSlSearchReadiness,
noIndexedSourcesSlSearchReadiness,
} from './agent-search-readiness.js';
describe('agent semantic-layer search readiness guidance', () => {
it('formats missing project guidance with exact recovery commands', () => {
expect(missingProjectSlSearchReadiness('/tmp/ktx-search', 'gross revenue')).toEqual({
code: 'agent_sl_search_missing_project',
message: 'Semantic-layer search needs an initialized KTX project at /tmp/ktx-search.',
nextSteps: [
'ktx setup --project-dir /tmp/ktx-search',
'ktx status --project-dir /tmp/ktx-search',
'ktx ingest run --connection-id <connection> --adapter <adapter>',
'ktx agent sl list --json --query "gross revenue" --project-dir /tmp/ktx-search',
],
});
});
it('formats no-connection and no-index guidance without hiding the project path', () => {
expect(noConnectionsSlSearchReadiness('/tmp/ktx-search', 'revenue')).toMatchObject({
code: 'agent_sl_search_no_connections',
message: 'Semantic-layer search found no configured connections in /tmp/ktx-search.',
});
expect(noIndexedSourcesSlSearchReadiness('/tmp/ktx-search', 'orders')).toMatchObject({
code: 'agent_sl_search_no_indexed_sources',
message: 'Semantic-layer search found no indexed semantic-layer sources in /tmp/ktx-search.',
});
});
it('formats unknown connection guidance', () => {
expect(missingConnectionSlSearchReadiness('/tmp/ktx-search', 'warehouse', 'revenue')).toMatchObject({
code: 'agent_sl_search_unknown_connection',
message: 'Semantic-layer search connection "warehouse" is not configured in /tmp/ktx-search.',
});
});
it('detects missing ktx.yaml read errors', () => {
const error = Object.assign(new Error('ENOENT: no such file or directory'), {
code: 'ENOENT',
path: '/tmp/ktx-search/ktx.yaml',
});
expect(isMissingProjectConfigError(error)).toBe(true);
expect(isMissingProjectConfigError(new Error('other'))).toBe(false);
});
});

View file

@ -1,94 +0,0 @@
export type KtxAgentSlSearchReadinessCode =
| 'agent_sl_search_missing_project'
| 'agent_sl_search_no_connections'
| 'agent_sl_search_unknown_connection'
| 'agent_sl_search_no_indexed_sources';
export interface KtxAgentSlSearchReadinessDetail {
code: KtxAgentSlSearchReadinessCode;
message: string;
nextSteps: string[];
}
function queryForCommand(query: string | undefined): string {
const trimmed = query?.trim();
return trimmed && trimmed.length > 0 ? trimmed : 'revenue';
}
function projectSearchCommand(projectDir: string, query: string | undefined): string {
return `ktx agent sl list --json --query ${JSON.stringify(queryForCommand(query))} --project-dir ${projectDir}`;
}
function baseNextSteps(projectDir: string, query: string | undefined): string[] {
return [
`ktx setup --project-dir ${projectDir}`,
`ktx status --project-dir ${projectDir}`,
'ktx ingest run --connection-id <connection> --adapter <adapter>',
projectSearchCommand(projectDir, query),
];
}
export function missingProjectSlSearchReadiness(
projectDir: string,
query: string | undefined,
): KtxAgentSlSearchReadinessDetail {
return {
code: 'agent_sl_search_missing_project',
message: `Semantic-layer search needs an initialized KTX project at ${projectDir}.`,
nextSteps: baseNextSteps(projectDir, query),
};
}
export function noConnectionsSlSearchReadiness(
projectDir: string,
query: string | undefined,
): KtxAgentSlSearchReadinessDetail {
return {
code: 'agent_sl_search_no_connections',
message: `Semantic-layer search found no configured connections in ${projectDir}.`,
nextSteps: baseNextSteps(projectDir, query),
};
}
export function missingConnectionSlSearchReadiness(
projectDir: string,
connectionId: string,
query: string | undefined,
): KtxAgentSlSearchReadinessDetail {
return {
code: 'agent_sl_search_unknown_connection',
message: `Semantic-layer search connection "${connectionId}" is not configured in ${projectDir}.`,
nextSteps: baseNextSteps(projectDir, query),
};
}
export function noIndexedSourcesSlSearchReadiness(
projectDir: string,
query: string | undefined,
): KtxAgentSlSearchReadinessDetail {
return {
code: 'agent_sl_search_no_indexed_sources',
message: `Semantic-layer search found no indexed semantic-layer sources in ${projectDir}.`,
nextSteps: baseNextSteps(projectDir, query),
};
}
function errorCode(error: unknown): string | undefined {
if (typeof error !== 'object' || error === null || !('code' in error)) {
return undefined;
}
const code = (error as { code?: unknown }).code;
return typeof code === 'string' ? code : undefined;
}
function errorPath(error: unknown): string | undefined {
if (typeof error !== 'object' || error === null || !('path' in error)) {
return undefined;
}
const path = (error as { path?: unknown }).path;
return typeof path === 'string' ? path : undefined;
}
export function isMissingProjectConfigError(error: unknown): boolean {
return errorCode(error) === 'ENOENT' && (errorPath(error)?.endsWith('ktx.yaml') ?? false);
}

View file

@ -1,428 +0,0 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { buildDefaultKtxProjectConfig } from '@ktx/context/project';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { runKtxAgent } from './agent.js';
import type { KtxAgentRuntime } from './agent-runtime.js';
function makeIo() {
let stdout = '';
let stderr = '';
return {
io: {
stdout: { write: (chunk: string) => (stdout += chunk) },
stderr: { write: (chunk: string) => (stderr += chunk) },
},
stdout: () => stdout,
stderr: () => stderr,
};
}
function runtime(overrides: Record<string, unknown> = {}): KtxAgentRuntime {
const config = buildDefaultKtxProjectConfig('revenue');
return {
project: {
projectDir: '/tmp/revenue',
configPath: '/tmp/revenue/ktx.yaml',
config: {
...config,
connections: {
warehouse: { driver: 'sqlite', path: 'warehouse.sqlite', readonly: true as const },
},
},
coreConfig: {} as KtxAgentRuntime['project']['coreConfig'],
git: {} as KtxAgentRuntime['project']['git'],
fileStore: {} as KtxAgentRuntime['project']['fileStore'],
},
ports: {
connections: { list: vi.fn(async () => [{ id: 'warehouse', name: 'warehouse', connectionType: 'sqlite' }]) },
semanticLayer: {
listSources: vi.fn(async () => ({
sources: [
{
connectionId: 'warehouse',
connectionName: 'warehouse',
name: 'orders',
columnCount: 2,
measureCount: 1,
joinCount: 0,
},
],
totalSources: 1,
})),
readSource: vi.fn(async () => ({ sourceName: 'orders', yaml: 'name: orders\n' })),
writeSource: vi.fn(async () => ({ success: true, sourceName: 'orders' })),
validate: vi.fn(async () => ({ success: true, errors: [], warnings: [] })),
query: vi.fn(async () => ({ sql: 'select 1', headers: ['x'], rows: [[1]], totalRows: 1, plan: {} })),
},
knowledge: {
search: vi.fn(async () => ({
results: [
{
key: 'page-1',
path: 'knowledge/global/page-1.md',
scope: 'GLOBAL' as const,
summary: 'Revenue logic',
score: 0.9,
matchReasons: ['lexical' as const],
},
],
totalFound: 1,
})),
read: vi.fn(async () => ({
key: 'page-1',
scope: 'GLOBAL' as const,
summary: 'Revenue logic',
content: 'Use net revenue.',
})),
write: vi.fn(async () => ({ success: true, key: 'page-1', action: 'created' as const })),
},
},
queryExecutor: {
execute: vi.fn(async () => ({ headers: ['x'], rows: [[1]], totalRows: 1, command: 'SELECT', rowCount: 1 })),
},
...overrides,
};
}
function runtimeWithoutConnections(): KtxAgentRuntime {
const base = runtime();
return {
...base,
project: {
...base.project,
config: {
...base.project.config,
connections: {},
},
},
ports: {
...base.ports,
semanticLayer: {
...base.ports.semanticLayer!,
listSources: vi.fn(async () => ({ sources: [], totalSources: 0 })),
},
},
};
}
describe('runKtxAgent', () => {
let tempDir: string;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'ktx-agent-'));
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
it('prints tool discovery with every stable command', async () => {
const io = makeIo();
await expect(runKtxAgent({ command: 'tools', projectDir: tempDir, json: true }, io.io)).resolves.toBe(0);
const body = JSON.parse(io.stdout());
expect(body.projectDir).toBe(tempDir);
expect(body.tools.map((tool: { name: string }) => tool.name)).toEqual([
'context',
'sl.list',
'sl.read',
'sl.query',
'wiki.search',
'wiki.read',
'sql.execute',
]);
expect(io.stderr()).toBe('');
});
it('prints project context from setup status, connections, and SL summaries', async () => {
const io = makeIo();
const createRuntime = vi.fn(async () => runtime());
const readSetupStatus = vi.fn(async () => ({ project: { path: tempDir, ready: true }, agents: [] }));
await expect(
runKtxAgent({ command: 'context', projectDir: tempDir, json: true }, io.io, { createRuntime, readSetupStatus }),
).resolves.toBe(0);
expect(JSON.parse(io.stdout())).toMatchObject({
projectDir: tempDir,
status: { project: { ready: true } },
connections: [{ id: 'warehouse' }],
semanticLayer: { totalSources: 1 },
});
});
it('dispatches SL list, SL read, wiki search, and wiki read through local ports', async () => {
for (const args of [
{ command: 'sl-list' as const, projectDir: tempDir, json: true as const, connectionId: 'warehouse' },
{
command: 'sl-read' as const,
projectDir: tempDir,
json: true as const,
connectionId: 'warehouse',
sourceName: 'orders',
},
{ command: 'wiki-search' as const, projectDir: tempDir, json: true as const, query: 'revenue', limit: 10 },
{ command: 'wiki-read' as const, projectDir: tempDir, json: true as const, pageId: 'page-1' },
]) {
const io = makeIo();
await expect(runKtxAgent(args, io.io, { createRuntime: async () => runtime() })).resolves.toBe(0);
expect(JSON.parse(io.stdout())).toBeTruthy();
expect(io.stderr()).toBe('');
}
});
it('prints wiki hybrid search metadata from the hidden agent wiki search command', async () => {
const fakeRuntime = runtime();
const knowledge = fakeRuntime.ports.knowledge;
if (!knowledge) {
throw new Error('Expected runtime knowledge port');
}
fakeRuntime.ports.knowledge = {
...knowledge,
search: vi.fn(async () => ({
results: [
{
key: 'metrics-revenue',
path: 'knowledge/global/metrics-revenue.md',
scope: 'GLOBAL' as const,
summary: 'Revenue metric definition',
score: 0.02459016393442623,
matchReasons: ['lexical' as const, 'token' as const],
},
],
totalFound: 1,
})),
};
const io = makeIo();
await expect(
runKtxAgent({ command: 'wiki-search', projectDir: tempDir, json: true, query: 'paid order', limit: 5 }, io.io, {
createRuntime: async () => fakeRuntime,
}),
).resolves.toBe(0);
expect(JSON.parse(io.stdout())).toEqual({
results: [
expect.objectContaining({
key: 'metrics-revenue',
path: 'knowledge/global/metrics-revenue.md',
matchReasons: ['lexical', 'token'],
}),
],
totalFound: 1,
});
});
it('executes SL queries from a JSON query file', async () => {
const queryFile = join(tempDir, 'sl-query.json');
const io = makeIo();
await writeFile(queryFile, '{"measures":["total_revenue"],"dimensions":[]}', 'utf-8');
await expect(
runKtxAgent(
{
command: 'sl-query',
projectDir: tempDir,
json: true,
connectionId: 'warehouse',
queryFile,
execute: true,
maxRows: 100,
cliVersion: '0.2.0',
runtimeInstallPolicy: 'never',
},
io.io,
{ createRuntime: async () => runtime() },
),
).resolves.toBe(0);
expect(JSON.parse(io.stdout())).toMatchObject({ sql: 'select 1', rows: [[1]] });
});
it('passes managed runtime options into default SL query runtime creation', async () => {
const queryFile = join(tempDir, 'sl-query.json');
const io = makeIo();
const createRuntime = vi.fn(async () => runtime());
await writeFile(queryFile, '{"measures":["total_revenue"],"dimensions":[]}', 'utf-8');
await expect(
runKtxAgent(
{
command: 'sl-query',
projectDir: tempDir,
json: true,
connectionId: 'warehouse',
queryFile,
execute: false,
cliVersion: '0.2.0',
runtimeInstallPolicy: 'auto',
},
io.io,
{ createRuntime },
),
).resolves.toBe(0);
expect(createRuntime).toHaveBeenCalledWith({
projectDir: tempDir,
enableSemanticCompute: true,
enableQueryExecution: false,
cliVersion: '0.2.0',
runtimeInstallPolicy: 'auto',
io: io.io,
});
});
it('executes read-only SQL from a SQL file with an explicit row limit', async () => {
const sqlFile = join(tempDir, 'query.sql');
const fakeRuntime = runtime();
const io = makeIo();
await writeFile(sqlFile, 'select 1', 'utf-8');
await expect(
runKtxAgent(
{
command: 'sql-execute',
projectDir: tempDir,
json: true,
connectionId: 'warehouse',
sqlFile,
maxRows: 100,
},
io.io,
{ createRuntime: async () => fakeRuntime as never },
),
).resolves.toBe(0);
expect(fakeRuntime.queryExecutor?.execute).toHaveBeenCalledWith({
connectionId: 'warehouse',
projectDir: '/tmp/revenue',
connection: { driver: 'sqlite', path: 'warehouse.sqlite', readonly: true },
sql: 'select 1',
maxRows: 100,
});
});
it('prints guided JSON when semantic-layer search runs outside a project', async () => {
const io = makeIo();
const missingProjectError = Object.assign(new Error('ENOENT: no such file or directory'), {
code: 'ENOENT',
path: join(tempDir, 'ktx.yaml'),
});
await expect(
runKtxAgent(
{ command: 'sl-list', projectDir: tempDir, json: true, query: 'gross revenue' },
io.io,
{ createRuntime: vi.fn(async () => Promise.reject(missingProjectError)) },
),
).resolves.toBe(1);
expect(JSON.parse(io.stderr())).toEqual({
ok: false,
error: {
code: 'agent_sl_search_missing_project',
message: `Semantic-layer search needs an initialized KTX project at ${tempDir}.`,
nextSteps: [
`ktx setup --project-dir ${tempDir}`,
`ktx status --project-dir ${tempDir}`,
'ktx ingest run --connection-id <connection> --adapter <adapter>',
`ktx agent sl list --json --query "gross revenue" --project-dir ${tempDir}`,
],
},
});
expect(io.stdout()).toBe('');
});
it('prints guided JSON when semantic-layer search has no configured connections', async () => {
const io = makeIo();
await expect(
runKtxAgent(
{ command: 'sl-list', projectDir: tempDir, json: true, query: 'revenue' },
io.io,
{ createRuntime: async () => runtimeWithoutConnections() },
),
).resolves.toBe(1);
expect(JSON.parse(io.stderr())).toMatchObject({
ok: false,
error: {
code: 'agent_sl_search_no_connections',
message: `Semantic-layer search found no configured connections in ${tempDir}.`,
nextSteps: [
`ktx setup --project-dir ${tempDir}`,
`ktx status --project-dir ${tempDir}`,
'ktx ingest run --connection-id <connection> --adapter <adapter>',
`ktx agent sl list --json --query "revenue" --project-dir ${tempDir}`,
],
},
});
});
it('prints guided JSON when semantic-layer search asks for an unknown connection', async () => {
const io = makeIo();
await expect(
runKtxAgent(
{ command: 'sl-list', projectDir: tempDir, json: true, connectionId: 'missing', query: 'revenue' },
io.io,
{ createRuntime: async () => runtime() },
),
).resolves.toBe(1);
expect(JSON.parse(io.stderr())).toMatchObject({
ok: false,
error: {
code: 'agent_sl_search_unknown_connection',
message: `Semantic-layer search connection "missing" is not configured in ${tempDir}.`,
},
});
});
it('prints guided JSON when semantic-layer search has no indexed sources', async () => {
const fakeRuntime = runtime();
const semanticLayer = fakeRuntime.ports.semanticLayer!;
fakeRuntime.ports.semanticLayer = {
...semanticLayer,
listSources: vi.fn(async () => ({ sources: [], totalSources: 0 })),
};
const io = makeIo();
await expect(
runKtxAgent(
{ command: 'sl-list', projectDir: tempDir, json: true, connectionId: 'warehouse', query: 'revenue' },
io.io,
{ createRuntime: async () => fakeRuntime },
),
).resolves.toBe(1);
expect(JSON.parse(io.stderr())).toMatchObject({
ok: false,
error: {
code: 'agent_sl_search_no_indexed_sources',
message: `Semantic-layer search found no indexed semantic-layer sources in ${tempDir}.`,
},
});
});
it('returns JSON errors when required ports or records are missing', async () => {
const io = makeIo();
await expect(
runKtxAgent({ command: 'wiki-read', projectDir: tempDir, json: true, pageId: 'missing' }, io.io, {
createRuntime: async () =>
runtime({
ports: { knowledge: { read: vi.fn(async () => null) } },
}) as never,
}),
).resolves.toBe(1);
expect(JSON.parse(io.stderr())).toMatchObject({
ok: false,
error: { message: expect.stringContaining('missing') },
});
});
});

View file

@ -1,219 +0,0 @@
import { readFile } from 'node:fs/promises';
import type { KtxCliIo } from './cli-runtime.js';
import {
createKtxAgentRuntime,
parseAgentMaxRows,
readAgentJsonFile,
writeAgentJson,
writeAgentJsonError,
type KtxAgentRuntime,
type KtxAgentRuntimeDeps,
} from './agent-runtime.js';
import {
isMissingProjectConfigError,
missingConnectionSlSearchReadiness,
missingProjectSlSearchReadiness,
noConnectionsSlSearchReadiness,
noIndexedSourcesSlSearchReadiness,
type KtxAgentSlSearchReadinessDetail,
} from './agent-search-readiness.js';
import type { KtxManagedPythonInstallPolicy } from './managed-python-command.js';
import { readKtxSetupStatus, type KtxSetupStatus } from './setup.js';
export type KtxAgentArgs =
| { command: 'tools'; projectDir: string; json: true }
| { command: 'context'; projectDir: string; json: true }
| { command: 'sl-list'; projectDir: string; json: true; connectionId?: string; query?: string }
| { command: 'sl-read'; projectDir: string; json: true; connectionId?: string; sourceName: string }
| {
command: 'sl-query';
projectDir: string;
json: true;
connectionId: string;
queryFile: string;
execute: boolean;
maxRows?: number;
cliVersion: string;
runtimeInstallPolicy: KtxManagedPythonInstallPolicy;
}
| { command: 'wiki-search'; projectDir: string; json: true; query: string; limit: number }
| { command: 'wiki-read'; projectDir: string; json: true; pageId: string }
| { command: 'sql-execute'; projectDir: string; json: true; connectionId: string; sqlFile: string; maxRows?: number };
export interface KtxAgentDeps extends KtxAgentRuntimeDeps {
createRuntime?: (options: {
projectDir: string;
enableSemanticCompute: boolean;
enableQueryExecution: boolean;
cliVersion?: string;
runtimeInstallPolicy?: KtxManagedPythonInstallPolicy;
io?: KtxCliIo;
}) => Promise<KtxAgentRuntime>;
readSetupStatus?: (
projectDir: string,
) => Promise<KtxSetupStatus | { project: { path?: string; ready: boolean }; agents: unknown[] }>;
}
const AGENT_TOOLS = [
{ name: 'context', command: 'ktx agent context --json' },
{ name: 'sl.list', command: 'ktx agent sl list --json [--connection-id <id>] [--query <text>]' },
{ name: 'sl.read', command: 'ktx agent sl read <sourceName> --json [--connection-id <id>]' },
{
name: 'sl.query',
command: 'ktx agent sl query --json --connection-id <id> --query-file <path> --execute --max-rows 100',
},
{ name: 'wiki.search', command: 'ktx agent wiki search <query> --json [--limit 10]' },
{ name: 'wiki.read', command: 'ktx agent wiki read <pageId> --json' },
{
name: 'sql.execute',
command: 'ktx agent sql execute --json --connection-id <id> --sql-file <path> --max-rows 100',
},
] as const;
function writeAgentSlSearchReadinessError(io: KtxCliIo, detail: KtxAgentSlSearchReadinessDetail): void {
writeAgentJsonError(io, detail.message, { code: detail.code, nextSteps: detail.nextSteps });
}
async function runtimeFor(args: KtxAgentArgs, deps: KtxAgentDeps, io: KtxCliIo): Promise<KtxAgentRuntime> {
const needsSemanticCompute = args.command === 'sl-query';
const needsQueryExecution = args.command === 'sql-execute' || (args.command === 'sl-query' && args.execute);
const runtimeOptions = {
projectDir: args.projectDir,
enableSemanticCompute: needsSemanticCompute,
enableQueryExecution: needsQueryExecution,
...(args.command === 'sl-query'
? {
cliVersion: args.cliVersion,
runtimeInstallPolicy: args.runtimeInstallPolicy,
io,
}
: {}),
};
return deps.createRuntime ? deps.createRuntime(runtimeOptions) : createKtxAgentRuntime(runtimeOptions, deps);
}
function connectionIdForSource(runtime: KtxAgentRuntime, requested: string | undefined): string {
if (requested) return requested;
const ids = Object.keys(runtime.project.config.connections ?? {});
if (ids.length === 1) return ids[0] as string;
throw new Error('Use --connection-id when the project has zero or multiple connections.');
}
export async function runKtxAgent(args: KtxAgentArgs, io: KtxCliIo, deps: KtxAgentDeps = {}): Promise<number> {
try {
if (args.command === 'tools') {
writeAgentJson(io, { projectDir: args.projectDir, tools: AGENT_TOOLS });
return 0;
}
const runtime = await runtimeFor(args, deps, io);
if (args.command === 'context') {
const [status, connections, semanticLayer] = await Promise.all([
(deps.readSetupStatus ?? readKtxSetupStatus)(args.projectDir),
runtime.ports.connections?.list() ?? [],
runtime.ports.semanticLayer?.listSources({}) ?? { sources: [], totalSources: 0 },
]);
writeAgentJson(io, { projectDir: args.projectDir, status, connections, semanticLayer, tools: AGENT_TOOLS });
return 0;
}
if (args.command === 'sl-list') {
const semanticLayer = runtime.ports.semanticLayer;
if (!semanticLayer) throw new Error('Semantic-layer tools are not available for this project.');
if (args.query) {
const connectionIds = Object.keys(runtime.project.config.connections ?? {});
if (args.connectionId && !runtime.project.config.connections[args.connectionId]) {
writeAgentSlSearchReadinessError(
io,
missingConnectionSlSearchReadiness(args.projectDir, args.connectionId, args.query),
);
return 1;
}
if (connectionIds.length === 0) {
writeAgentSlSearchReadinessError(io, noConnectionsSlSearchReadiness(args.projectDir, args.query));
return 1;
}
}
const listed = await semanticLayer.listSources({ connectionId: args.connectionId, query: args.query });
if (args.query && listed.sources.length === 0) {
const allSources = await semanticLayer.listSources({ connectionId: args.connectionId });
if (allSources.totalSources === 0) {
writeAgentSlSearchReadinessError(io, noIndexedSourcesSlSearchReadiness(args.projectDir, args.query));
return 1;
}
}
writeAgentJson(io, listed);
return 0;
}
if (args.command === 'sl-read') {
const semanticLayer = runtime.ports.semanticLayer;
if (!semanticLayer) throw new Error('Semantic-layer tools are not available for this project.');
const source = await semanticLayer.readSource({
connectionId: connectionIdForSource(runtime, args.connectionId),
sourceName: args.sourceName,
});
if (!source) throw new Error(`Semantic-layer source "${args.sourceName}" was not found.`);
writeAgentJson(io, source);
return 0;
}
if (args.command === 'sl-query') {
const semanticLayer = runtime.ports.semanticLayer;
if (!semanticLayer) throw new Error('Semantic-layer tools are not available for this project.');
const query = await readAgentJsonFile(args.queryFile);
const maxRows = args.execute ? parseAgentMaxRows(args.maxRows) : args.maxRows;
writeAgentJson(
io,
await semanticLayer.query({
connectionId: args.connectionId,
query: { ...query, ...(maxRows !== undefined ? { limit: maxRows } : {}) } as never,
}),
);
return 0;
}
if (args.command === 'wiki-search') {
const knowledge = runtime.ports.knowledge;
if (!knowledge) throw new Error('Wiki tools are not available for this project.');
writeAgentJson(io, await knowledge.search({ userId: 'agent', query: args.query, limit: args.limit }));
return 0;
}
if (args.command === 'wiki-read') {
const knowledge = runtime.ports.knowledge;
if (!knowledge) throw new Error('Wiki tools are not available for this project.');
const page = await knowledge.read({ userId: 'agent', key: args.pageId });
if (!page) throw new Error(`Wiki page "${args.pageId}" was not found.`);
writeAgentJson(io, page);
return 0;
}
const queryExecutor = runtime.queryExecutor;
if (!queryExecutor) throw new Error('SQL execution is not available for this project.');
const connection = runtime.project.config.connections[args.connectionId];
if (!connection) throw new Error(`Connection "${args.connectionId}" was not found.`);
const maxRows = parseAgentMaxRows(args.maxRows);
writeAgentJson(
io,
await queryExecutor.execute({
connectionId: args.connectionId,
projectDir: runtime.project.projectDir,
connection,
sql: await readFile(args.sqlFile, 'utf-8'),
maxRows,
}),
);
return 0;
} catch (error) {
if (args.command === 'sl-list' && args.query && isMissingProjectConfigError(error)) {
writeAgentSlSearchReadinessError(io, missingProjectSlSearchReadiness(args.projectDir, args.query));
return 1;
}
writeAgentJsonError(io, error instanceof Error ? error.message : String(error));
return 1;
}
}

View file

@ -1,6 +1,5 @@
import { Command, InvalidArgumentError } from '@commander-js/extra-typings';
import type { KtxCliDeps, KtxCliIo, KtxCliPackageInfo } from './cli-runtime.js';
import { registerAgentCommands } from './commands/agent-commands.js';
import { registerConnectionCommands } from './commands/connection-commands.js';
import { registerIngestCommands } from './commands/ingest-commands.js';
import { registerWikiCommands } from './commands/knowledge-commands.js';
@ -321,7 +320,6 @@ export function buildKtxProgram(options: BuildKtxProgramOptions): Command {
registerWikiCommands(program, context);
registerSlCommands(program, context);
registerStatusCommands(program, context);
registerAgentCommands(program, context);
registerDevCommands(program, context);
return program;

View file

@ -2,7 +2,6 @@ import { createRequire } from 'node:module';
import type { KtxConnectionMetabaseSetupArgs } from './commands/connection-metabase-setup.js';
import type { KtxConnectionNotionArgs } from './commands/connection-notion.js';
import type { KtxAgentArgs } from './agent.js';
import type { KtxConnectionArgs } from './connection.js';
import type { KtxDoctorArgs } from './doctor.js';
import type { KtxIngestArgs } from './ingest.js';
@ -30,7 +29,6 @@ export interface KtxCliIo {
export interface KtxCliDeps {
setup?: (args: KtxSetupArgs, io: KtxCliIo) => Promise<number>;
agent?: (args: KtxAgentArgs, io: KtxCliIo) => Promise<number>;
connection?: (args: KtxConnectionArgs, io: KtxCliIo) => Promise<number>;
connectionNotion?: (args: KtxConnectionNotionArgs, io: KtxCliIo) => Promise<number>;
connectionMetabaseSetup?: (args: KtxConnectionMetabaseSetupArgs, io: KtxCliIo) => Promise<number>;

View file

@ -53,15 +53,18 @@ export const slQueryCommandSchema = z.object({
command: z.literal('query'),
projectDir: projectDirSchema,
connectionId: z.string().min(1).optional(),
query: z.object({
measures: z.array(z.string().min(1)).min(1),
dimensions: stringArraySchema,
filters: stringArraySchema.optional(),
segments: stringArraySchema.optional(),
order_by: z.array(orderBySchema).optional(),
limit: z.number().int().positive().optional(),
include_empty: z.literal(true).optional(),
}),
query: z
.object({
measures: z.array(z.string().min(1)).min(1),
dimensions: stringArraySchema,
filters: stringArraySchema.optional(),
segments: stringArraySchema.optional(),
order_by: z.array(orderBySchema).optional(),
limit: z.number().int().positive().optional(),
include_empty: z.literal(true).optional(),
})
.optional(),
queryFile: z.string().min(1).optional(),
format: z.enum(['json', 'sql']),
execute: z.boolean(),
cliVersion: z.string().min(1),

View file

@ -1,149 +0,0 @@
import { Option, type Command } from '@commander-js/extra-typings';
import type { KtxAgentArgs } from '../agent.js';
import type { KtxCliCommandContext } from '../cli-program.js';
import { parsePositiveIntegerOption, resolveCommandProjectDir } from '../cli-program.js';
import { runtimeInstallPolicyFromFlags } from '../managed-python-command.js';
async function runAgent(context: KtxCliCommandContext, args: KtxAgentArgs): Promise<void> {
const runner = context.deps.agent ?? (await import('../agent.js')).runKtxAgent;
context.setExitCode(await runner(args, context.io));
}
function jsonOption(): Option {
return new Option('--json', 'Print JSON output').makeOptionMandatory();
}
export function registerAgentCommands(program: Command, context: KtxCliCommandContext): void {
const agent = program
.command('agent', { hidden: true })
.description('Machine-readable KTX commands for coding agents')
.showHelpAfterError();
agent.hook('preAction', (_thisCommand, actionCommand) => {
context.writeDebug?.('agent', actionCommand);
});
agent
.command('tools')
.description('Print available agent-facing KTX tools')
.addOption(jsonOption())
.action(async (_options, command) => {
await runAgent(context, { command: 'tools', projectDir: resolveCommandProjectDir(command), json: true });
});
agent
.command('context')
.description('Print project context for agent planning')
.addOption(jsonOption())
.action(async (_options, command) => {
await runAgent(context, { command: 'context', projectDir: resolveCommandProjectDir(command), json: true });
});
const sl = agent.command('sl').description('Semantic-layer agent commands');
sl.command('list')
.description('List semantic-layer sources')
.addOption(jsonOption())
.option('--connection-id <id>', 'Filter by connection id')
.option('--query <text>', 'Search source names and descriptions')
.action(async (options: { connectionId?: string; query?: string }, command) => {
await runAgent(context, {
command: 'sl-list',
projectDir: resolveCommandProjectDir(command),
json: true,
...(options.connectionId ? { connectionId: options.connectionId } : {}),
...(options.query ? { query: options.query } : {}),
});
});
sl.command('read')
.description('Read one semantic-layer source')
.argument('<sourceName>')
.addOption(jsonOption())
.option('--connection-id <id>', 'Connection id containing the source')
.action(async (sourceName: string, options: { connectionId?: string }, command) => {
await runAgent(context, {
command: 'sl-read',
projectDir: resolveCommandProjectDir(command),
json: true,
sourceName,
...(options.connectionId ? { connectionId: options.connectionId } : {}),
});
});
sl.command('query')
.description('Run a semantic-layer query JSON file')
.addOption(jsonOption())
.requiredOption('--connection-id <id>', 'Connection id for execution')
.requiredOption('--query-file <path>', 'JSON semantic-layer query file')
.option('--execute', 'Execute the compiled query against the connection', false)
.option('--yes', 'Install the managed Python runtime without prompting when required', false)
.option('--no-input', 'Disable interactive managed runtime installation')
.option('--max-rows <number>', 'Maximum rows to return when executing', parsePositiveIntegerOption)
.action(
async (
options: {
connectionId: string;
queryFile: string;
execute: boolean;
maxRows?: number;
yes?: boolean;
input?: boolean;
},
command,
) => {
await runAgent(context, {
command: 'sl-query',
projectDir: resolveCommandProjectDir(command),
json: true,
connectionId: options.connectionId,
queryFile: options.queryFile,
execute: options.execute,
cliVersion: context.packageInfo.version,
runtimeInstallPolicy: runtimeInstallPolicyFromFlags(options),
...(options.maxRows !== undefined ? { maxRows: options.maxRows } : {}),
});
},
);
const wiki = agent.command('wiki').description('KTX wiki agent commands');
wiki
.command('search')
.description('Search KTX wiki pages')
.argument('<query>')
.addOption(jsonOption())
.option('--limit <number>', 'Maximum search results', parsePositiveIntegerOption, 10)
.action(async (query: string, options: { limit: number }, command) => {
await runAgent(context, {
command: 'wiki-search',
projectDir: resolveCommandProjectDir(command),
json: true,
query,
limit: options.limit,
});
});
wiki
.command('read')
.description('Read one KTX wiki page')
.argument('<pageId>')
.addOption(jsonOption())
.action(async (pageId: string, _options, command) => {
await runAgent(context, { command: 'wiki-read', projectDir: resolveCommandProjectDir(command), json: true, pageId });
});
const sql = agent.command('sql').description('Safe SQL execution commands');
sql
.command('execute')
.description('Execute read-only SQL with a row limit')
.addOption(jsonOption())
.requiredOption('--connection-id <id>', 'Connection id for execution')
.requiredOption('--sql-file <path>', 'SQL file to execute')
.requiredOption('--max-rows <number>', 'Maximum rows to return', parsePositiveIntegerOption)
.action(async (options: { connectionId: string; sqlFile: string; maxRows: number }, command) => {
await runAgent(context, {
command: 'sql-execute',
projectDir: resolveCommandProjectDir(command),
json: true,
connectionId: options.connectionId,
sqlFile: options.sqlFile,
maxRows: options.maxRows,
});
});
}

View file

@ -1,5 +1,10 @@
import { type Command, Option } from '@commander-js/extra-typings';
import { collectOption, type KtxCliCommandContext, resolveCommandProjectDir } from '../cli-program.js';
import {
collectOption,
type KtxCliCommandContext,
parsePositiveIntegerOption,
resolveCommandProjectDir,
} from '../cli-program.js';
import { wikiWriteCommandSchema } from '../command-schemas.js';
import type { KtxKnowledgeArgs } from '../knowledge.js';
import { profileMark } from '../startup-profile.js';
@ -24,12 +29,14 @@ export function registerWikiCommands(program: Command, context: KtxCliCommandCon
wiki
.command('list')
.description('List local wiki pages')
.option('--json', 'Print JSON output', false)
.option('--user-id <id>', 'Local user id', 'local')
.action(async (options: { userId: string }, command) => {
.action(async (options: { userId: string; json?: boolean }, command) => {
await runKnowledgeArgs(context, {
command: 'list',
projectDir: resolveCommandProjectDir(command),
userId: options.userId,
json: options.json,
});
});
@ -37,13 +44,15 @@ export function registerWikiCommands(program: Command, context: KtxCliCommandCon
.command('read')
.description('Read one local wiki page')
.argument('<key>', 'Wiki page key')
.option('--json', 'Print JSON output', false)
.option('--user-id <id>', 'Local user id', 'local')
.action(async (key: string, options: { userId: string }, command) => {
.action(async (key: string, options: { userId: string; json?: boolean }, command) => {
await runKnowledgeArgs(context, {
command: 'read',
projectDir: resolveCommandProjectDir(command),
key,
userId: options.userId,
json: options.json,
});
});
@ -51,13 +60,17 @@ export function registerWikiCommands(program: Command, context: KtxCliCommandCon
.command('search')
.description('Search local wiki pages')
.argument('<query>', 'Search query')
.option('--json', 'Print JSON output', false)
.option('--user-id <id>', 'Local user id', 'local')
.action(async (query: string, options: { userId: string }, command) => {
.option('--limit <number>', 'Maximum search results', parsePositiveIntegerOption)
.action(async (query: string, options: { userId: string; json?: boolean; limit?: number }, command) => {
await runKnowledgeArgs(context, {
command: 'search',
projectDir: resolveCommandProjectDir(command),
query,
userId: options.userId,
json: options.json,
...(options.limit !== undefined ? { limit: options.limit } : {}),
});
});

View file

@ -51,6 +51,7 @@ export function registerSlCommands(program: Command, context: KtxCliCommandConte
sl.command('list')
.description('List semantic-layer sources')
.option('--connection-id <id>', 'KTX connection id')
.option('--query <text>', 'Search source names and descriptions')
.addOption(
new Option('--output <mode>', 'Output mode: pretty (default in TTY), plain (TSV), or json').choices([
'pretty',
@ -59,26 +60,34 @@ export function registerSlCommands(program: Command, context: KtxCliCommandConte
]),
)
.option('--json', 'Shortcut for --output=json (overrides --output)', false)
.action(async (options: { connectionId?: string; output?: 'pretty' | 'plain' | 'json'; json?: boolean }, command) => {
.action(
async (
options: { connectionId?: string; query?: string; output?: 'pretty' | 'plain' | 'json'; json?: boolean },
command,
) => {
await runSlArgs(context, {
command: 'list',
projectDir: resolveCommandProjectDir(command),
connectionId: options.connectionId,
query: options.query,
output: options.output,
json: options.json,
});
});
},
);
sl.command('read')
.description('Read a semantic-layer source')
.argument('<sourceName>', 'Semantic-layer source name')
.requiredOption('--connection-id <id>', 'KTX connection id')
.action(async (sourceName: string, options: { connectionId: string }, command) => {
.option('--json', 'Print JSON output', false)
.action(async (sourceName: string, options: { connectionId: string; json?: boolean }, command) => {
await runSlArgs(context, {
command: 'read',
projectDir: resolveCommandProjectDir(command),
connectionId: options.connectionId,
sourceName,
json: options.json,
});
});
@ -113,6 +122,7 @@ export function registerSlCommands(program: Command, context: KtxCliCommandConte
sl.command('query')
.description('Compile or execute a semantic-layer query')
.option('--connection-id <id>', 'KTX connection id')
.option('--query-file <path>', 'JSON semantic-layer query file')
.option('--measure <measure>', 'Measure to query; repeatable', collectOption, [])
.option('--dimension <dimension>', 'Dimension to include; repeatable', collectOption, [])
.option('--filter <filter>', 'Filter expression; repeatable', collectOption, [])
@ -126,22 +136,26 @@ export function registerSlCommands(program: Command, context: KtxCliCommandConte
.option('--no-input', 'Disable interactive managed runtime installation')
.option('--max-rows <n>', 'Maximum rows to return when executing', parsePositiveIntegerOption)
.action(async (options, command) => {
if (options.measure.length === 0) {
if (options.measure.length === 0 && !options.queryFile) {
throw new Error('sl query requires at least one --measure');
}
const args = slQueryCommandSchema.parse({
command: 'query',
projectDir: resolveCommandProjectDir(command),
connectionId: options.connectionId,
query: {
measures: options.measure,
dimensions: options.dimension,
...(options.filter.length > 0 ? { filters: options.filter } : {}),
...(options.segment.length > 0 ? { segments: options.segment } : {}),
...(options.orderBy.length > 0 ? { order_by: options.orderBy } : {}),
...(options.limit !== undefined ? { limit: options.limit } : {}),
...(options.includeEmpty === true ? { include_empty: true } : {}),
},
...(options.queryFile
? { queryFile: options.queryFile }
: {
query: {
measures: options.measure,
dimensions: options.dimension,
...(options.filter.length > 0 ? { filters: options.filter } : {}),
...(options.segment.length > 0 ? { segments: options.segment } : {}),
...(options.orderBy.length > 0 ? { order_by: options.orderBy } : {}),
...(options.limit !== undefined ? { limit: options.limit } : {}),
...(options.includeEmpty === true ? { include_empty: true } : {}),
},
}),
format: options.format,
execute: options.execute === true,
cliVersion: context.packageInfo.version,

View file

@ -73,26 +73,27 @@ describe('standalone local warehouse example', () => {
const projectDir = await copyExampleProject(tempDir);
const sourceDir = join(projectDir, 'source');
const knowledgeList = await runBuiltCli(['agent', 'wiki', 'search', 'revenue', '--json', '--project-dir', projectDir]);
const knowledgeList = await runBuiltCli(['wiki', 'search', 'revenue', '--json', '--project-dir', projectDir]);
expect(knowledgeList).toMatchObject({ code: 0, stderr: '' });
expect(parseJsonOutput<{ results: Array<{ key: string; summary: string }> }>(knowledgeList.stdout).results).toContainEqual(
expect.objectContaining({ key: 'revenue', summary: 'Paid order value after refunds' }),
);
expect(
parseJsonOutput<{ data: { items: Array<{ key: string; summary: string }> } }>(knowledgeList.stdout).data.items,
).toContainEqual(expect.objectContaining({ key: 'revenue', summary: 'Paid order value after refunds' }));
const knowledgeRead = await runBuiltCli(['agent', 'wiki', 'read', 'revenue', '--json', '--project-dir', projectDir]);
const knowledgeRead = await runBuiltCli(['wiki', 'read', 'revenue', '--json', '--project-dir', projectDir]);
expect(knowledgeRead).toMatchObject({ code: 0, stderr: '' });
expect(parseJsonOutput<{ content: string }>(knowledgeRead.stdout).content).toContain(
expect(parseJsonOutput<{ data: { content: string } }>(knowledgeRead.stdout).data.content).toContain(
'Revenue is paid order amount after refund adjustments.',
);
const slList = await runBuiltCli(['agent', 'sl', 'list', '--json', '--project-dir', projectDir, '--connection-id', 'warehouse']);
const slList = await runBuiltCli(['sl', 'list', '--json', '--project-dir', projectDir, '--connection-id', 'warehouse']);
expect(slList).toMatchObject({ code: 0, stderr: '' });
expect(parseJsonOutput<{ sources: Array<{ connectionId: string; name: string; columnCount: number }> }>(slList.stdout).sources).toContainEqual(
expect.objectContaining({ connectionId: 'warehouse', name: 'orders', columnCount: 3 }),
);
expect(
parseJsonOutput<{ data: { items: Array<{ connectionId: string; name: string; columnCount: number }> } }>(
slList.stdout,
).data.items,
).toContainEqual(expect.objectContaining({ connectionId: 'warehouse', name: 'orders', columnCount: 3 }));
const slRead = await runBuiltCli([
'agent',
'sl',
'read',
'orders',
@ -103,7 +104,7 @@ describe('standalone local warehouse example', () => {
projectDir,
]);
expect(slRead).toMatchObject({ code: 0, stderr: '' });
expect(parseJsonOutput<{ yaml: string }>(slRead.stdout).yaml).toContain('name: orders');
expect(parseJsonOutput<{ data: { yaml: string } }>(slRead.stdout).data.yaml).toContain('name: orders');
const ingest = await runBuiltCli([
'ingest',

View file

@ -1141,136 +1141,28 @@ describe('runKtxCli', () => {
expect(setupIo.stderr()).toContain('Choose only one Historic SQL action');
});
it('registers hidden agent help and tools discovery without showing agent in root help', async () => {
const helpIo = makeIo();
const toolsIo = makeIo();
const agent = vi.fn(async () => 0);
it('rejects the removed hidden agent command', async () => {
const io = makeIo();
await expect(runKtxCli(['agent', '--help'], helpIo.io, { agent })).resolves.toBe(0);
await expect(
runKtxCli(['--project-dir', tempDir, 'agent', 'tools', '--json'], toolsIo.io, { agent }),
).resolves.toBe(0);
await expect(runKtxCli(['agent'], io.io)).resolves.toBe(1);
expect(helpIo.stdout()).toContain('Usage: ktx agent');
expect(toolsIo.stderr()).toBe('');
expect(agent).toHaveBeenCalledWith({ command: 'tools', projectDir: tempDir, json: true }, toolsIo.io);
expect(io.stderr()).toContain("unknown command 'agent'");
expect(io.stdout()).toBe('');
});
it('dispatches full hidden agent commands without exposing agent in root help', async () => {
const agent = vi.fn(async () => 0);
const cases = [
{
argv: ['--project-dir', tempDir, 'agent', 'context', '--json'],
args: { command: 'context', projectDir: tempDir, json: true },
},
{
argv: [
'--project-dir',
tempDir,
'agent',
'sl',
'list',
'--json',
'--connection-id',
'warehouse',
'--query',
'orders',
],
args: { command: 'sl-list', projectDir: tempDir, json: true, connectionId: 'warehouse', query: 'orders' },
},
{
argv: ['--project-dir', tempDir, 'agent', 'sl', 'read', 'orders', '--json', '--connection-id', 'warehouse'],
args: { command: 'sl-read', projectDir: tempDir, json: true, sourceName: 'orders', connectionId: 'warehouse' },
},
{
argv: [
'--project-dir',
tempDir,
'agent',
'sl',
'query',
'--json',
'--connection-id',
'warehouse',
'--query-file',
'/tmp/query.json',
'--execute',
'--max-rows',
'100',
],
args: {
command: 'sl-query',
projectDir: tempDir,
json: true,
connectionId: 'warehouse',
queryFile: '/tmp/query.json',
execute: true,
maxRows: 100,
cliVersion: '0.0.0-private',
runtimeInstallPolicy: 'prompt',
},
},
{
argv: ['--project-dir', tempDir, 'agent', 'wiki', 'search', 'revenue', '--json', '--limit', '5'],
args: { command: 'wiki-search', projectDir: tempDir, json: true, query: 'revenue', limit: 5 },
},
{
argv: ['--project-dir', tempDir, 'agent', 'wiki', 'read', 'page-1', '--json'],
args: { command: 'wiki-read', projectDir: tempDir, json: true, pageId: 'page-1' },
},
{
argv: [
'--project-dir',
tempDir,
'agent',
'sql',
'execute',
'--json',
'--connection-id',
'warehouse',
'--sql-file',
'/tmp/query.sql',
'--max-rows',
'100',
],
args: {
command: 'sql-execute',
projectDir: tempDir,
json: true,
connectionId: 'warehouse',
sqlFile: '/tmp/query.sql',
maxRows: 100,
},
},
];
for (const entry of cases) {
const io = makeIo();
await expect(runKtxCli(entry.argv, io.io, { agent })).resolves.toBe(0);
expect(agent).toHaveBeenLastCalledWith(entry.args, io.io);
expect(io.stderr()).toBe('');
}
const helpIo = makeIo();
await expect(runKtxCli(['--help'], helpIo.io, { agent })).resolves.toBe(0);
expect(helpIo.stdout()).not.toContain('agent ');
});
it('routes hidden agent SL query managed runtime policies', async () => {
it('routes public SL query files with managed runtime policies', async () => {
const autoIo = makeIo();
const neverIo = makeIo();
const conflictIo = makeIo();
const agent = vi.fn(async () => 0);
const sl = vi.fn(async () => 0);
await expect(
runKtxCli(
[
'--project-dir',
tempDir,
'agent',
'sl',
'query',
'--json',
'--connection-id',
'warehouse',
'--query-file',
@ -1278,7 +1170,7 @@ describe('runKtxCli', () => {
'--yes',
],
autoIo.io,
{ agent },
{ sl },
),
).resolves.toBe(0);
@ -1287,10 +1179,8 @@ describe('runKtxCli', () => {
[
'--project-dir',
tempDir,
'agent',
'sl',
'query',
'--json',
'--connection-id',
'warehouse',
'--query-file',
@ -1298,7 +1188,7 @@ describe('runKtxCli', () => {
'--no-input',
],
neverIo.io,
{ agent },
{ sl },
),
).resolves.toBe(0);
@ -1307,10 +1197,8 @@ describe('runKtxCli', () => {
[
'--project-dir',
tempDir,
'agent',
'sl',
'query',
'--json',
'--connection-id',
'warehouse',
'--query-file',
@ -1319,33 +1207,33 @@ describe('runKtxCli', () => {
'--no-input',
],
conflictIo.io,
{ agent },
{ sl },
),
).resolves.toBe(1);
expect(agent).toHaveBeenNthCalledWith(
expect(sl).toHaveBeenNthCalledWith(
1,
{
command: 'sl-query',
command: 'query',
projectDir: tempDir,
json: true,
connectionId: 'warehouse',
queryFile: '/tmp/query.json',
execute: false,
format: 'json',
cliVersion: '0.0.0-private',
runtimeInstallPolicy: 'auto',
},
autoIo.io,
);
expect(agent).toHaveBeenNthCalledWith(
expect(sl).toHaveBeenNthCalledWith(
2,
{
command: 'sl-query',
command: 'query',
projectDir: tempDir,
json: true,
connectionId: 'warehouse',
queryFile: '/tmp/query.json',
execute: false,
format: 'json',
cliVersion: '0.0.0-private',
runtimeInstallPolicy: 'never',
},
@ -1354,112 +1242,6 @@ describe('runKtxCli', () => {
expect(conflictIo.stderr()).toContain('Choose only one runtime install mode: --yes or --no-input');
});
it('prints semantic-layer hybrid search metadata from the hidden agent sl list command', async () => {
const agent = vi.fn(async (args, io) => {
expect(args).toEqual({
command: 'sl-list',
projectDir: tempDir,
json: true,
connectionId: 'warehouse',
query: 'paid',
});
io.stdout.write(
`${JSON.stringify(
{
sources: [
{
connectionId: 'warehouse',
connectionName: 'warehouse',
name: 'orders',
columnCount: 2,
measureCount: 1,
joinCount: 0,
score: 0.03278688524590164,
matchReasons: ['dictionary'],
dictionaryMatches: [{ column: 'status', values: ['paid'] }],
},
],
totalSources: 1,
},
null,
2,
)}\n`,
);
return 0;
});
const io = makeIo();
await expect(
runKtxCli(
['--project-dir', tempDir, 'agent', 'sl', 'list', '--json', '--connection-id', 'warehouse', '--query', 'paid'],
io.io,
{ agent },
),
).resolves.toBe(0);
expect(JSON.parse(io.stdout())).toEqual({
sources: [
expect.objectContaining({
connectionId: 'warehouse',
name: 'orders',
matchReasons: ['dictionary'],
dictionaryMatches: [{ column: 'status', values: ['paid'] }],
}),
],
totalSources: 1,
});
});
it('prints wiki hybrid search metadata from the hidden agent wiki search command', async () => {
const agent = vi.fn(async (args, io) => {
expect(args).toEqual({
command: 'wiki-search',
projectDir: tempDir,
json: true,
query: 'paid order',
limit: 5,
});
io.stdout.write(
`${JSON.stringify(
{
results: [
{
key: 'metrics-revenue',
path: 'knowledge/global/metrics-revenue.md',
scope: 'GLOBAL',
summary: 'Revenue metric definition',
score: 0.02459016393442623,
matchReasons: ['lexical', 'token'],
},
],
totalFound: 1,
},
null,
2,
)}\n`,
);
return 0;
});
const io = makeIo();
await expect(
runKtxCli(['--project-dir', tempDir, 'agent', 'wiki', 'search', 'paid order', '--json', '--limit', '5'], io.io, {
agent,
}),
).resolves.toBe(0);
expect(JSON.parse(io.stdout())).toEqual({
results: [
expect.objectContaining({
key: 'metrics-revenue',
path: 'knowledge/global/metrics-revenue.md',
matchReasons: ['lexical', 'token'],
}),
],
totalFound: 1,
});
});
it('dispatches public connection subcommands through the existing connection implementation', async () => {
const tempDir = await mkdtemp(join(tmpdir(), 'ktx-connection-dispatch-'));
const connection = vi.fn(async () => 0);

View file

@ -9,17 +9,6 @@ export {
type KtxCliIo,
type KtxCliPackageInfo,
} from './cli-runtime.js';
export { runKtxAgent, type KtxAgentArgs } from './agent.js';
export {
KTX_AGENT_MAX_ROWS_CAP,
createKtxAgentRuntime,
parseAgentMaxRows,
readAgentJsonFile,
writeAgentJson,
writeAgentJsonError,
type KtxAgentRuntime,
type KtxAgentRuntimeDeps,
} from './agent-runtime.js';
export { runKtxSetup, type KtxSetupArgs, type KtxSetupStatus } from './setup.js';
export type {
KtxSetupDatabaseDriver,

View file

@ -93,6 +93,65 @@ describe('runKtxKnowledge', () => {
expect(searchIo.stdout()).toContain('metrics-revenue');
});
it('prints wiki list, search, and read 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);
const listIo = makeIo();
await expect(runKtxKnowledge({ command: 'list', projectDir, userId: 'local', json: true }, listIo.io)).resolves.toBe(
0,
);
expect(JSON.parse(listIo.stdout())).toMatchObject({
kind: 'list',
data: { items: [expect.objectContaining({ key: 'metrics-revenue', summary: 'Revenue' })] },
meta: { command: 'wiki list' },
});
const searchIo = makeIo();
await expect(
runKtxKnowledge(
{ command: 'search', projectDir, query: 'paid order', userId: 'local', json: true, limit: 5 },
searchIo.io,
),
).resolves.toBe(0);
expect(JSON.parse(searchIo.stdout())).toMatchObject({
kind: 'list',
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' });

View file

@ -11,11 +11,12 @@ import {
searchLocalKnowledgePages,
writeLocalKnowledgePage,
} from '@ktx/context/wiki';
import { writeJsonResult } from './io/print-list.js';
export type KtxKnowledgeArgs =
| { command: 'list'; projectDir: string; userId: string }
| { command: 'read'; projectDir: string; key: string; userId: string }
| { command: 'search'; projectDir: string; query: string; userId: string }
| { command: 'list'; projectDir: string; userId: string; json?: boolean }
| { command: 'read'; projectDir: string; key: string; userId: string; json?: boolean }
| { command: 'search'; projectDir: string; query: string; userId: string; json?: boolean; limit?: number }
| {
command: 'write';
projectDir: string;
@ -61,6 +62,14 @@ export async function runKtxKnowledge(
const project = await loadKtxProject({ projectDir: args.projectDir });
if (args.command === 'list') {
const pages = await listLocalKnowledgePages(project, { userId: args.userId });
if (args.json) {
writeJsonResult(io, {
kind: 'list',
data: { items: pages },
meta: { command: 'wiki list' },
});
return 0;
}
for (const page of pages) {
io.stdout.write(`${page.scope}\t${page.key}\t${page.summary}\n`);
}
@ -71,6 +80,14 @@ export async function runKtxKnowledge(
if (!page) {
throw new Error(`Knowledge 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`);
@ -82,7 +99,16 @@ export async function runKtxKnowledge(
query: args.query,
userId: args.userId,
embeddingService: wikiSearchEmbeddingService(project, deps),
limit: args.limit,
});
if (args.json) {
writeJsonResult(io, {
kind: 'list',
data: { items: results },
meta: { command: 'wiki search' },
});
return 0;
}
if (results.length === 0) {
const pages = await listLocalKnowledgePages(project, { userId: args.userId });
if (pages.length === 0) {

View file

@ -25,12 +25,8 @@ describe('KTX demo next steps', () => {
it('uses supported final public commands', () => {
expect(KTX_NEXT_STEP_COMMANDS).toEqual([
{
command: 'ktx agent context --json',
description: 'Verify the project context your agent can read',
},
{
command: 'ktx agent tools --json',
description: 'List direct CLI tools available to agents',
command: 'ktx status --json',
description: 'Verify project setup and context readiness',
},
{
command: 'ktx sl list',
@ -46,8 +42,8 @@ describe('KTX demo next steps', () => {
it('uses only the direct CLI route for agent verification', () => {
const commands = KTX_NEXT_STEP_COMMANDS.map((step) => step.command);
expect(commands).toContain('ktx agent context --json');
expect(commands).toContain('ktx agent tools --json');
expect(commands).not.toContain('ktx agent context --json');
expect(commands).toContain('ktx status --json');
expect(commands).not.toContain('ktx serve --mcp stdio --user-id local');
});
@ -64,8 +60,8 @@ describe('KTX demo next steps', () => {
it('does not advertise removed Commander migration commands', () => {
const rendered = formatNextStepLines().join('\n');
expect(rendered).toContain('ktx agent tools --json');
expect(rendered).toContain('ktx agent context --json');
expect(rendered).toContain('ktx status --json');
expect(rendered).not.toContain('ktx agent');
expect(rendered).toContain('ktx sl list');
expect(rendered).toContain('ktx wiki list');
@ -109,7 +105,8 @@ describe('KTX demo next steps', () => {
}).join('\n');
expect(rendered).toContain('KTX context is ready for agents.');
expect(rendered).toContain('ktx agent context --json');
expect(rendered).toContain('ktx status --json');
expect(rendered).not.toContain('ktx agent');
expect(rendered).not.toContain('ktx serve --mcp stdio --user-id local');
expect(rendered).not.toContain('Build KTX context next.');
});

View file

@ -11,12 +11,8 @@ export const KTX_CONTEXT_BUILD_COMMANDS = [
export const KTX_NEXT_STEP_DIRECT_COMMANDS = [
{
command: 'ktx agent context --json',
description: 'Verify the project context your agent can read',
},
{
command: 'ktx agent tools --json',
description: 'List direct CLI tools available to agents',
command: 'ktx status --json',
description: 'Verify project setup and context readiness',
},
{
command: 'ktx sl list',

View file

@ -35,8 +35,7 @@ describe('project directory defaults', () => {
const ingest = vi.fn(async () => 0);
const scan = vi.fn(async () => 0);
const setup = vi.fn(async () => 0);
const agent = vi.fn(async () => 0);
const deps: KtxCliDeps = { agent, connection, doctor, ingest, scan, setup };
const deps: KtxCliDeps = { connection, doctor, ingest, scan, setup };
const cases: Array<{
argv: string[];
@ -74,12 +73,6 @@ describe('project directory defaults', () => {
expected: { command: 'run', projectDir: '/tmp/ktx-env-project', connectionId: 'warehouse' },
expectedStderr: 'Project: /tmp/ktx-env-project\n',
},
{
argv: ['agent', 'tools', '--json'],
spy: agent,
expected: { command: 'tools', projectDir: '/tmp/ktx-env-project' },
expectedStderr: '',
},
];
for (const item of cases) {

View file

@ -84,7 +84,10 @@ describe('setup agents', () => {
const skill = await readFile(join(tempDir, '.agents/skills/ktx/SKILL.md'), 'utf-8');
expect(skill).toContain(`--project-dir ${tempDir}`);
expect(skill).toContain('must not print secrets');
expect(skill).toContain('agent sql execute');
expect(skill).toContain('status --json');
expect(skill).toContain('sl list --json');
expect(skill).not.toContain('agent ');
expect(skill).not.toContain('sql execute');
expect(await readKtxAgentInstallManifest(tempDir)).toMatchObject({
version: 1,
projectDir: tempDir,
@ -115,8 +118,9 @@ describe('setup agents', () => {
const skill = await readFile(join(tempDir, '.agents/skills/ktx/SKILL.md'), 'utf-8');
expect(skill).not.toContain('`ktx agent');
expect(skill).toContain('agent context --json');
expect(skill).toContain('agent sql execute');
expect(skill).toContain('status --json');
expect(skill).toContain('sl query');
expect(skill).not.toContain('sql execute');
});
it('removes only manifest-listed files', async () => {

View file

@ -124,7 +124,7 @@ function cliInstructionContent(input: { projectDir: string; launcher: KtxCliLaun
return [
'---',
'name: ktx',
'description: Use local KTX semantic context, wiki knowledge, and safe SQL execution for this project.',
'description: Use local KTX semantic context and wiki knowledge for this project.',
'---',
'',
'# KTX Local Context',
@ -137,11 +137,11 @@ function cliInstructionContent(input: { projectDir: string; launcher: KtxCliLaun
'',
'Available commands:',
'',
`- \`${ktxCommandLine(input.launcher, ['agent', 'context', ...projectDirArgs])}\``,
`- \`${ktxCommandLine(input.launcher, ['agent', 'sl', 'list', ...projectDirArgs])}\``,
`- \`${ktxCommandLine(input.launcher, ['agent', 'sl', 'read', '<sourceName>', ...projectDirArgs])}\``,
`- \`${ktxCommandLine(input.launcher, ['status', ...projectDirArgs])}\``,
`- \`${ktxCommandLine(input.launcher, ['sl', 'list', ...projectDirArgs])}\``,
`- \`${ktxCommandLine(input.launcher, ['sl', 'list', ...projectDirArgs, '--query', '<text>'])}\``,
`- \`${ktxCommandLine(input.launcher, ['sl', 'read', '<sourceName>', ...projectDirArgs, '--connection-id', '<id>'])}\``,
`- \`${ktxCommandLine(input.launcher, [
'agent',
'sl',
'query',
...projectDirArgs,
@ -153,29 +153,17 @@ function cliInstructionContent(input: { projectDir: string; launcher: KtxCliLaun
'--max-rows',
'100',
])}\``,
`- \`${ktxCommandLine(input.launcher, ['agent', 'wiki', 'search', '<query>', ...projectDirArgs])}\``,
`- \`${ktxCommandLine(input.launcher, ['agent', 'wiki', 'read', '<pageId>', ...projectDirArgs])}\``,
`- \`${ktxCommandLine(input.launcher, [
'agent',
'sql',
'execute',
...projectDirArgs,
'--connection-id',
'<id>',
'--sql-file',
'<path>',
'--max-rows',
'100',
])}\``,
`- \`${ktxCommandLine(input.launcher, ['wiki', 'search', '<query>', ...projectDirArgs, '--limit', '10'])}\``,
`- \`${ktxCommandLine(input.launcher, ['wiki', 'read', '<pageId>', ...projectDirArgs])}\``,
'',
'SQL execution is read-only, requires an explicit row limit, and should use the smallest useful limit.',
'Use semantic-layer queries before direct database access. Do not print secrets or credential references.',
'',
].join('\n');
}
function ruleInstructionContent(input: { projectDir: string }): string {
return [
`Use the \`ktx\` CLI to query local semantic context, wiki knowledge, and execute safe SQL for this project (\`--project-dir ${input.projectDir}\`).`,
`Use the \`ktx\` CLI to query local semantic context and wiki knowledge for this project (\`--project-dir ${input.projectDir}\`).`,
'',
'Use when the user asks about data schemas, metrics, dimensions, database structure, or wants to run SQL queries.',
'',

View file

@ -84,6 +84,71 @@ describe('runKtxSl', () => {
expect(listIo.stdout()).toContain('warehouse\torders\tcolumns=1\tmeasures=0\tjoins=0');
});
it('prints semantic-layer reads and searched lists as public JSON envelopes', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir, projectName: 'warehouse' });
await expect(
runKtxSl(
{
command: 'write',
projectDir,
connectionId: 'warehouse',
sourceName: 'orders',
yaml: [
'name: orders',
'table: public.orders',
'description: Paid order facts',
'grain: [order_id]',
'columns:',
' - name: order_id',
' type: string',
'',
].join('\n'),
},
makeIo().io,
),
).resolves.toBe(0);
const readIo = makeIo();
await expect(
runKtxSl(
{ command: 'read', projectDir, connectionId: 'warehouse', sourceName: 'orders', json: true },
readIo.io,
),
).resolves.toBe(0);
expect(JSON.parse(readIo.stdout())).toMatchObject({
kind: 'sl.source',
data: {
connectionId: 'warehouse',
name: 'orders',
yaml: expect.stringContaining('name: orders'),
},
});
const listIo = makeIo();
await expect(
runKtxSl(
{ command: 'list', projectDir, connectionId: 'warehouse', query: 'paid', json: true },
listIo.io,
),
).resolves.toBe(0);
expect(JSON.parse(listIo.stdout())).toMatchObject({
kind: 'list',
data: {
items: [
expect.objectContaining({
connectionId: 'warehouse',
name: 'orders',
score: expect.any(Number),
matchReasons: expect.arrayContaining(['token']),
}),
],
},
meta: { command: 'sl list' },
});
});
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' });
@ -191,6 +256,73 @@ joins: []
expect(stderr.write).not.toHaveBeenCalled();
});
it('runs sl query from a JSON query file', async () => {
const projectDir = join(tempDir, 'project');
const project = await initKtxProject({ projectDir, projectName: 'warehouse' });
project.config.connections.warehouse = { driver: 'postgres', readonly: true };
await project.fileStore.writeFile(
'semantic-layer/warehouse/orders.yaml',
`name: orders
table: public.orders
grain: [id]
columns:
- name: id
type: number
measures:
- name: order_count
expr: count(*)
joins: []
`,
'ktx',
'ktx@example.com',
'Add orders source',
);
const queryFile = join(tempDir, 'query.json');
await writeFile(queryFile, '{"measures":["orders.order_count"],"dimensions":[]}', 'utf-8');
const stdout = { write: vi.fn() };
const stderr = { write: vi.fn() };
const query = vi.fn(async () => ({
sql: 'select count(*) as order_count from public.orders',
dialect: 'postgres',
columns: [{ name: 'orders.order_count' }],
plan: {},
}));
const createSemanticLayerCompute = vi.fn(() => ({
query,
validateSources: vi.fn(),
generateSources: vi.fn(),
}));
await expect(
runKtxSl(
{
command: 'query',
projectDir,
connectionId: 'warehouse',
queryFile,
format: 'json',
execute: false,
cliVersion: '0.2.0',
runtimeInstallPolicy: 'auto',
},
{ stdout, stderr },
{ createSemanticLayerCompute },
),
).resolves.toBe(0);
expect(query).toHaveBeenCalledWith(
expect.objectContaining({
query: { measures: ['orders.order_count'], dimensions: [] },
}),
);
expect(JSON.parse(String(stdout.write.mock.calls[0][0]))).toMatchObject({
sql: 'select count(*) as order_count from public.orders',
plan: { execution: { mode: 'compile_only' } },
});
expect(stderr.write).not.toHaveBeenCalled();
});
it('creates default sl query compute through the managed runtime helper', async () => {
const projectDir = join(tempDir, 'project');
const project = await initKtxProject({ projectDir, projectName: 'warehouse' });

View file

@ -1,14 +1,22 @@
import { readFile } from 'node:fs/promises';
import { createDefaultLocalQueryExecutor, type KtxSqlQueryExecutorPort } from '@ktx/context/connections';
import {
createLocalKtxEmbeddingProviderFromConfig,
KtxIngestEmbeddingPortAdapter,
type KtxEmbeddingPort,
} from '@ktx/context';
import type { KtxSemanticLayerComputePort } from '@ktx/context/daemon';
import { loadKtxProject, type KtxLocalProject } from '@ktx/context/project';
import {
compileLocalSlQuery,
listLocalSlSources,
readLocalSlSource,
searchLocalSlSources,
validateLocalSlSource,
writeLocalSlSource,
type SemanticLayerQueryInput,
} from '@ktx/context/sl';
import { writeJsonResult } from './io/print-list.js';
import {
createManagedPythonSemanticLayerComputePort,
type KtxManagedPythonInstallPolicy,
@ -20,15 +28,16 @@ profileMark('module:sl');
type SlQueryFormat = 'json' | 'sql';
export type KtxSlArgs =
| { command: 'list'; projectDir: string; connectionId?: string; output?: string; json?: boolean }
| { command: 'read'; projectDir: string; connectionId: string; sourceName: string }
| { command: 'list'; projectDir: string; connectionId?: string; query?: string; output?: string; json?: boolean }
| { command: 'read'; projectDir: string; connectionId: string; sourceName: string; json?: boolean }
| { command: 'validate'; projectDir: string; connectionId: string; sourceName: string }
| { command: 'write'; projectDir: string; connectionId: string; sourceName: string; yaml: string }
| {
command: 'query';
projectDir: string;
connectionId?: string;
query: SemanticLayerQueryInput;
query?: SemanticLayerQueryInput;
queryFile?: string;
format: SlQueryFormat;
execute: boolean;
maxRows?: number;
@ -43,6 +52,8 @@ interface KtxSlIo {
interface KtxSlDeps {
loadProject?: typeof loadKtxProject;
embeddingService?: KtxEmbeddingPort | null;
createEmbeddingProvider?: typeof createLocalKtxEmbeddingProviderFromConfig;
createSemanticLayerCompute?: () => KtxSemanticLayerComputePort;
createManagedSemanticLayerCompute?: (options: {
cliVersion: string;
@ -52,11 +63,35 @@ interface KtxSlDeps {
createQueryExecutor?: () => KtxSqlQueryExecutorPort;
}
function slSearchEmbeddingService(project: KtxLocalProject, deps: KtxSlDeps): KtxEmbeddingPort | null {
if ('embeddingService' in deps) {
return deps.embeddingService ?? null;
}
const provider = (deps.createEmbeddingProvider ?? createLocalKtxEmbeddingProviderFromConfig)(
project.config.ingest.embeddings,
);
return provider ? new KtxIngestEmbeddingPortAdapter(provider) : null;
}
async function readSlQueryFile(path: string): Promise<SemanticLayerQueryInput> {
const parsed = JSON.parse(await readFile(path, 'utf-8')) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error(`${path} must contain a JSON object.`);
}
return parsed as SemanticLayerQueryInput;
}
export async function runKtxSl(args: KtxSlArgs, io: KtxSlIo = process, deps: KtxSlDeps = {}): Promise<number> {
try {
const project = await (deps.loadProject ?? loadKtxProject)({ projectDir: args.projectDir });
if (args.command === 'list') {
const sources = await listLocalSlSources(project, { connectionId: args.connectionId });
const sources = args.query
? await searchLocalSlSources(project, {
connectionId: args.connectionId,
query: args.query,
embeddingService: slSearchEmbeddingService(project, deps),
})
: await listLocalSlSources(project, { connectionId: args.connectionId });
const { resolveOutputMode } = await import('./io/mode.js');
const { printList } = await import('./io/print-list.js');
const mode = resolveOutputMode({ explicit: args.output, json: args.json, io });
@ -86,6 +121,14 @@ export async function runKtxSl(args: KtxSlArgs, io: KtxSlIo = process, deps: Ktx
if (!source) {
throw new Error(`Semantic-layer source "${args.connectionId}/${args.sourceName}" was not found`);
}
if (args.json) {
writeJsonResult(io, {
kind: 'sl.source',
data: source,
meta: { command: 'sl read' },
});
return 0;
}
io.stdout.write(source.yaml);
return 0;
}
@ -108,6 +151,10 @@ export async function runKtxSl(args: KtxSlArgs, io: KtxSlIo = process, deps: Ktx
return 0;
}
if (args.command === 'query') {
const query = args.query ?? (args.queryFile ? await readSlQueryFile(args.queryFile) : undefined);
if (!query) {
throw new Error('sl query requires query input from --query-file or at least one --measure');
}
const compute = deps.createSemanticLayerCompute
? deps.createSemanticLayerCompute()
: await (deps.createManagedSemanticLayerCompute ?? createManagedPythonSemanticLayerComputePort)({
@ -118,7 +165,7 @@ export async function runKtxSl(args: KtxSlArgs, io: KtxSlIo = process, deps: Ktx
const queryExecutor = args.execute ? (deps.createQueryExecutor ?? createDefaultLocalQueryExecutor)() : undefined;
const result = await compileLocalSlQuery(project as KtxLocalProject, {
connectionId: args.connectionId,
query: args.query,
query,
compute,
execute: args.execute,
maxRows: args.maxRows,

View file

@ -190,49 +190,21 @@ describe('standalone built ktx CLI smoke', () => {
);
});
it('prints guided JSON for agent semantic-layer search outside a project through the built binary', async () => {
const projectDir = join(tempDir, 'missing-search-project');
await mkdir(projectDir, { recursive: true });
const result = await runBuiltCli([
'agent',
'sl',
'list',
'--json',
'--query',
'revenue',
'--project-dir',
projectDir,
]);
it('rejects the removed agent command through the built binary', async () => {
const result = await runBuiltCli(['agent']);
expect(result.code).toBe(1);
expect(result.stdout).toBe('');
const errorJson = parseJsonOutput<{
ok: false;
error: { code: string; message: string; nextSteps: string[] };
}>(result.stderr);
expect(errorJson).toEqual({
ok: false,
error: {
code: 'agent_sl_search_missing_project',
message: `Semantic-layer search needs an initialized KTX project at ${projectDir}.`,
nextSteps: [
`ktx setup --project-dir ${projectDir}`,
`ktx status --project-dir ${projectDir}`,
'ktx ingest run --connection-id <connection> --adapter <adapter>',
`ktx agent sl list --json --query "revenue" --project-dir ${projectDir}`,
],
},
});
expect(result.stderr).toContain("unknown command 'agent'");
});
it('runs doctor setup through the built binary', async () => {
const result = await runBuiltCli(['status', '--no-input']);
expect(result.stdout).toContain('KTX setup doctor');
expect(result.stdout).toMatch(/KTX (setup|project) doctor/);
expect(result.stdout).toContain('Node 22+');
expect(result.stdout).toContain('Workspace-local CLI');
expect(result.stderr).toBe('');
expect(result.stderr === '' || result.stderr.startsWith('Project: ')).toBe(true);
expect([0, 1]).toContain(result.code);
});

View file

@ -46,7 +46,8 @@ describe('PGlite hybrid search runtime boundary', () => {
}
const productionRoutingFiles = [
'packages/cli/src/agent.ts',
'packages/cli/src/sl.ts',
'packages/cli/src/knowledge.ts',
'packages/context/src/mcp/local-project-ports.ts',
'packages/context/src/wiki/local-knowledge.ts',
'packages/context/src/ingest/context-evidence/sqlite-context-evidence-store.ts',

View file

@ -156,14 +156,12 @@ describe('standalone example docs', () => {
const servingAgents = await readText('docs-site/content/docs/guides/serving-agents.mdx');
for (const command of [
'ktx agent tools --json',
'ktx agent context --json',
'ktx agent sl list --json',
'ktx agent sl read orders --json',
'ktx agent sl query --json',
'ktx agent wiki search "revenue recognition" --json',
'ktx agent wiki read order-status-definitions --json',
'ktx agent sql execute --json',
'ktx status --json',
'ktx sl list --json',
'ktx sl read orders --json',
'ktx sl query --json',
'ktx wiki search "revenue recognition" --json',
'ktx wiki read order-status-definitions --json',
]) {
assert.match(servingAgents, new RegExp(command.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
}

View file

@ -557,12 +557,6 @@ function parseJsonResultWithExitCode(label, result, expectedCode) {
return JSON.parse(result.stdout);
}
function parseJsonFailure(label, result) {
assert.equal(result.code, 1, label + ' should fail with exit code 1');
assert.equal(result.stdout, '', label + ' should not write stdout when failing');
return JSON.parse(result.stderr);
}
function requireIncludes(values, expected, label) {
assert.ok(Array.isArray(values), label + ' must be an array');
assert.ok(values.includes(expected), label + ' did not include ' + expected + ': ' + values.join(', '));
@ -612,30 +606,6 @@ try {
assert.equal(runtimeStatusBefore.layout.runtimeRoot, process.env.KTX_RUNTIME_ROOT);
process.stdout.write('ktx managed runtime starts missing in isolated root\\n');
const missingProjectDir = join(root, 'missing-project');
await mkdir(missingProjectDir, { recursive: true });
const missingProjectSearch = await run('pnpm', [
'exec',
'ktx',
'agent',
'sl',
'list',
'--json',
'--query',
'revenue',
'--project-dir',
missingProjectDir,
]);
const missingProjectError = parseJsonFailure('ktx agent sl list missing project', missingProjectSearch);
assert.equal(missingProjectError.error.code, 'agent_sl_search_missing_project');
assert.deepEqual(missingProjectError.error.nextSteps, [
'ktx setup --project-dir ' + missingProjectDir,
'ktx status --project-dir ' + missingProjectDir,
'ktx ingest run --connection-id <connection> --adapter <adapter>',
'ktx agent sl list --json --query "revenue" --project-dir ' + missingProjectDir,
]);
process.stdout.write('ktx agent sl list missing project guidance verified\\n');
const init = await run('pnpm', [
'exec',
'ktx',
@ -671,28 +641,6 @@ try {
'--skip-agents',
]);
requireProjectStderr('ktx setup empty project', emptyInit, emptyProjectDir);
const emptySearch = await run('pnpm', [
'exec',
'ktx',
'agent',
'sl',
'list',
'--json',
'--query',
'revenue',
'--project-dir',
emptyProjectDir,
]);
const emptySearchError = parseJsonFailure('ktx agent sl list no connections', emptySearch);
assert.equal(emptySearchError.error.code, 'agent_sl_search_no_connections');
assert.deepEqual(emptySearchError.error.nextSteps, [
'ktx setup --project-dir ' + emptyProjectDir,
'ktx status --project-dir ' + emptyProjectDir,
'ktx ingest run --connection-id <connection> --adapter <adapter>',
'ktx agent sl list --json --query "revenue" --project-dir ' + emptyProjectDir,
]);
process.stdout.write('ktx agent sl list no connections guidance verified\\n');
await writeFile(
join(projectDir, 'ktx.yaml'),
[
@ -737,10 +685,9 @@ try {
'utf-8',
);
const agentWikiSearch = await run('pnpm', [
const wikiSearch = await run('pnpm', [
'exec',
'ktx',
'agent',
'wiki',
'search',
'revenue',
@ -750,40 +697,17 @@ try {
'--project-dir',
projectDir,
]);
const agentWikiSearchJson = parseJsonResult('ktx agent wiki search', agentWikiSearch);
assert.equal(agentWikiSearchJson.totalFound, 1);
assert.equal(agentWikiSearchJson.results[0].key, 'revenue');
assert.equal(agentWikiSearchJson.results[0].path, 'knowledge/global/revenue.md');
assert.equal(typeof agentWikiSearchJson.results[0].score, 'number');
requireIncludes(agentWikiSearchJson.results[0].matchReasons, 'lexical', 'agent wiki search match reasons');
process.stdout.write('ktx agent wiki search hybrid metadata verified\\n');
const wikiSearchJson = parseJsonResult('ktx wiki search', wikiSearch);
assert.equal(wikiSearchJson.kind, 'list');
assert.equal(wikiSearchJson.data.items.length, 1);
assert.equal(wikiSearchJson.data.items[0].key, 'revenue');
assert.equal(wikiSearchJson.data.items[0].path, 'knowledge/global/revenue.md');
assert.equal(typeof wikiSearchJson.data.items[0].score, 'number');
requireIncludes(wikiSearchJson.data.items[0].matchReasons, 'lexical', 'wiki search match reasons');
process.stdout.write('ktx wiki search hybrid metadata verified\\n');
await access(join(projectDir, '.ktx', 'db.sqlite'));
process.stdout.write('SQLite knowledge index: ' + join(projectDir, '.ktx', 'db.sqlite') + '\\n');
const noSourceSearch = await run('pnpm', [
'exec',
'ktx',
'agent',
'sl',
'list',
'--json',
'--connection-id',
'warehouse',
'--query',
'revenue',
'--project-dir',
projectDir,
]);
const noSourceSearchError = parseJsonFailure('ktx agent sl list no indexed sources', noSourceSearch);
assert.equal(noSourceSearchError.error.code, 'agent_sl_search_no_indexed_sources');
assert.deepEqual(noSourceSearchError.error.nextSteps, [
'ktx setup --project-dir ' + projectDir,
'ktx status --project-dir ' + projectDir,
'ktx ingest run --connection-id <connection> --adapter <adapter>',
'ktx agent sl list --json --query "revenue" --project-dir ' + projectDir,
]);
process.stdout.write('ktx agent sl list no indexed sources guidance verified\\n');
const slYaml = [
'name: orders',
'table: orders',
@ -804,10 +728,9 @@ try {
await mkdir(join(projectDir, 'semantic-layer', 'warehouse'), { recursive: true });
await writeFile(join(projectDir, 'semantic-layer', 'warehouse', 'orders.yaml'), slYaml, 'utf-8');
const agentSlSearch = await run('pnpm', [
const slSearch = await run('pnpm', [
'exec',
'ktx',
'agent',
'sl',
'list',
'--json',
@ -818,13 +741,14 @@ try {
'--project-dir',
projectDir,
]);
const agentSlSearchJson = parseJsonResult('ktx agent sl list', agentSlSearch);
assert.equal(agentSlSearchJson.totalSources, 1);
assert.equal(agentSlSearchJson.sources[0].connectionId, 'warehouse');
assert.equal(agentSlSearchJson.sources[0].name, 'orders');
assert.equal(typeof agentSlSearchJson.sources[0].score, 'number');
requireIncludes(agentSlSearchJson.sources[0].matchReasons, 'lexical', 'agent sl search match reasons');
process.stdout.write('ktx agent sl list hybrid metadata verified\\n');
const slSearchJson = parseJsonResult('ktx sl list', slSearch);
assert.equal(slSearchJson.kind, 'list');
assert.equal(slSearchJson.data.items.length, 1);
assert.equal(slSearchJson.data.items[0].connectionId, 'warehouse');
assert.equal(slSearchJson.data.items[0].name, 'orders');
assert.equal(typeof slSearchJson.data.items[0].score, 'number');
requireIncludes(slSearchJson.data.items[0].matchReasons, 'lexical', 'sl search match reasons');
process.stdout.write('ktx sl list hybrid metadata verified\\n');
const slQuery = await run('pnpm', ['exec', 'ktx', 'sl', 'query',
'--connection-id',

View file

@ -461,9 +461,9 @@ describe('verification snippets', () => {
assert.doesNotMatch(source, /startSemanticDaemon/);
assert.match(source, /run\('pnpm', \[\s*'exec',\s*'ktx',\s*'setup'/);
assert.match(source, /knowledge', 'global', 'revenue\.md'/);
assert.match(source, /run\('pnpm', \[\s*'exec',\s*'ktx',\s*'agent',\s*'wiki',\s*'search'/);
assert.match(source, /run\('pnpm', \[\s*'exec',\s*'ktx',\s*'wiki',\s*'search'/);
assert.match(source, /semantic-layer', 'warehouse', 'orders\.yaml'/);
assert.match(source, /run\('pnpm', \[\s*'exec',\s*'ktx',\s*'agent',\s*'sl',\s*'list'/);
assert.match(source, /run\('pnpm', \[\s*'exec',\s*'ktx',\s*'sl',\s*'list'/);
assert.match(source, /orders\.order_count/);
assert.match(source, /sqlite3/);
assert.match(source, /driver: sqlite/);