mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-07 07:55:13 +02:00
feat(cli)!: remove ktx agent command (#58)
* feat(cli)!: remove ktx agent command * test(context): update PGlite boundary guardrail
This commit is contained in:
parent
eaaabb361e
commit
721f1a998f
41 changed files with 500 additions and 1895 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@
|
|||
"ktx-sl",
|
||||
"ktx-wiki",
|
||||
"ktx-status",
|
||||
"ktx-agent",
|
||||
"ktx-dev"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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') },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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 } : {}),
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
'',
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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, '\\$&')));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue