mirror of
https://github.com/Kaelio/ktx.git
synced 2026-07-13 11:22:11 +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 className="h-2" />
|
||||||
<div>
|
<div>
|
||||||
<span className="term-prompt">$</span>{" "}
|
<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" />
|
<span className="term-cursor ml-1" />
|
||||||
</div>
|
</div>
|
||||||
</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 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 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 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
|
## Operating workflow
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,8 @@ Every docs page has a Markdown route:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
https://docs.kaelio.com/ktx/docs/getting-started/quickstart.md
|
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
|
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 |
|
| Flag | Description | Default |
|
||||||
|------|-------------|---------|
|
|------|-------------|---------|
|
||||||
| `--connection-id <id>` | Filter by KTX connection id | — |
|
| `--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` |
|
| `--output <mode>` | Output mode: `pretty` (default in TTY), `plain` (TSV), or `json` | `pretty` |
|
||||||
| `--json` | Shortcut for `--output=json` (overrides `--output`) | `false` |
|
| `--json` | Shortcut for `--output=json` (overrides `--output`) | `false` |
|
||||||
|
|
||||||
|
|
@ -36,6 +37,7 @@ ktx sl <subcommand> [options]
|
||||||
| Flag | Description | Default |
|
| Flag | Description | Default |
|
||||||
|------|-------------|---------|
|
|------|-------------|---------|
|
||||||
| `--connection-id <id>` | KTX connection id (required) | — |
|
| `--connection-id <id>` | KTX connection id (required) | — |
|
||||||
|
| `--json` | Print JSON output | `false` |
|
||||||
|
|
||||||
### `sl validate`
|
### `sl validate`
|
||||||
|
|
||||||
|
|
@ -55,6 +57,7 @@ ktx sl <subcommand> [options]
|
||||||
| Flag | Description | Default |
|
| Flag | Description | Default |
|
||||||
|------|-------------|---------|
|
|------|-------------|---------|
|
||||||
| `--connection-id <id>` | KTX connection id | — |
|
| `--connection-id <id>` | KTX connection id | — |
|
||||||
|
| `--query-file <path>` | JSON semantic-layer query file | — |
|
||||||
| `--measure <measure>` | Measure to query; repeatable (at least one required) | — |
|
| `--measure <measure>` | Measure to query; repeatable (at least one required) | — |
|
||||||
| `--dimension <dimension>` | Dimension to include; repeatable | — |
|
| `--dimension <dimension>` | Dimension to include; repeatable | — |
|
||||||
| `--filter <filter>` | Filter expression; repeatable | — |
|
| `--filter <filter>` | Filter expression; repeatable | — |
|
||||||
|
|
@ -78,9 +81,15 @@ ktx sl list --connection-id my-warehouse
|
||||||
# List sources as JSON
|
# List sources as JSON
|
||||||
ktx sl list --json
|
ktx sl list --json
|
||||||
|
|
||||||
|
# Search sources as JSON
|
||||||
|
ktx sl list --json --query "revenue"
|
||||||
|
|
||||||
# Read a source definition
|
# Read a source definition
|
||||||
ktx sl read orders --connection-id my-warehouse
|
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
|
# Validate a source against the live schema
|
||||||
ktx sl validate orders --connection-id my-warehouse
|
ktx sl validate orders --connection-id my-warehouse
|
||||||
|
|
||||||
|
|
@ -119,6 +128,13 @@ ktx sl query \
|
||||||
--dimension orders.created_date \
|
--dimension orders.created_date \
|
||||||
--execute \
|
--execute \
|
||||||
--max-rows 1000
|
--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
|
## Output
|
||||||
|
|
|
||||||
|
|
@ -26,19 +26,23 @@ ktx wiki <subcommand> [options]
|
||||||
|
|
||||||
| Flag | Description | Default |
|
| Flag | Description | Default |
|
||||||
|------|-------------|---------|
|
|------|-------------|---------|
|
||||||
|
| `--json` | Print JSON output | `false` |
|
||||||
| `--user-id <id>` | Local user id | `local` |
|
| `--user-id <id>` | Local user id | `local` |
|
||||||
|
|
||||||
### `wiki read`
|
### `wiki read`
|
||||||
|
|
||||||
| Flag | Description | Default |
|
| Flag | Description | Default |
|
||||||
|------|-------------|---------|
|
|------|-------------|---------|
|
||||||
|
| `--json` | Print JSON output | `false` |
|
||||||
| `--user-id <id>` | Local user id | `local` |
|
| `--user-id <id>` | Local user id | `local` |
|
||||||
|
|
||||||
### `wiki search`
|
### `wiki search`
|
||||||
|
|
||||||
| Flag | Description | Default |
|
| Flag | Description | Default |
|
||||||
|------|-------------|---------|
|
|------|-------------|---------|
|
||||||
|
| `--json` | Print JSON output | `false` |
|
||||||
| `--user-id <id>` | Local user id | `local` |
|
| `--user-id <id>` | Local user id | `local` |
|
||||||
|
| `--limit <number>` | Maximum search results | — |
|
||||||
|
|
||||||
### `wiki write`
|
### `wiki write`
|
||||||
|
|
||||||
|
|
@ -58,12 +62,21 @@ ktx wiki <subcommand> [options]
|
||||||
# List all wiki pages
|
# List all wiki pages
|
||||||
ktx wiki list
|
ktx wiki list
|
||||||
|
|
||||||
|
# List all wiki pages as JSON
|
||||||
|
ktx wiki list --json
|
||||||
|
|
||||||
# Read a specific wiki page
|
# Read a specific wiki page
|
||||||
ktx wiki read revenue-definitions
|
ktx wiki read revenue-definitions
|
||||||
|
|
||||||
|
# Read a specific wiki page as JSON
|
||||||
|
ktx wiki read revenue-definitions --json
|
||||||
|
|
||||||
# Search wiki pages
|
# Search wiki pages
|
||||||
ktx wiki search "monthly recurring revenue"
|
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
|
# Write a global knowledge page
|
||||||
ktx wiki write revenue-definitions \
|
ktx wiki write revenue-definitions \
|
||||||
--summary "Canonical revenue metric definitions" \
|
--summary "Canonical revenue metric definitions" \
|
||||||
|
|
@ -97,13 +110,16 @@ Wiki commands print local knowledge pages and search results. Agents should sear
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"results": [
|
"kind": "list",
|
||||||
{
|
"data": {
|
||||||
"key": "revenue-definitions",
|
"items": [
|
||||||
"summary": "Canonical revenue metric definitions",
|
{
|
||||||
"score": 0.92
|
"key": "revenue-definitions",
|
||||||
}
|
"summary": "Canonical revenue metric definitions",
|
||||||
]
|
"score": 0.92
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@
|
||||||
"ktx-sl",
|
"ktx-sl",
|
||||||
"ktx-wiki",
|
"ktx-wiki",
|
||||||
"ktx-status",
|
"ktx-status",
|
||||||
"ktx-agent",
|
|
||||||
"ktx-dev"
|
"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 |
|
| `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/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 |
|
| `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
|
## Verify it worked
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,37 +3,36 @@ title: Serving Agents
|
||||||
description: Expose your context to Claude Code, Cursor, Codex, and other coding 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
|
Once you've built and refined your context, expose it to coding agents through
|
||||||
coding agents. KTX provides machine-readable CLI commands for direct terminal
|
the public KTX CLI. Claude Code, Cursor, Codex, OpenCode, and custom agent
|
||||||
access from Claude Code, Cursor, Codex, OpenCode, and custom agent workflows.
|
workflows can call the same commands you use at a terminal.
|
||||||
|
|
||||||
## CLI Commands
|
## CLI Commands
|
||||||
|
|
||||||
KTX provides a set of machine-readable commands under `ktx agent`. These return
|
KTX public commands support JSON output for the context reads that agents use
|
||||||
JSON output designed for programmatic consumption.
|
most often. Use `--project-dir` when the agent is not already running inside the
|
||||||
|
KTX project directory.
|
||||||
|
|
||||||
### Available commands
|
### Available commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# List available tools and their descriptions
|
# Check setup and context readiness
|
||||||
ktx agent tools --json
|
ktx status --json
|
||||||
|
|
||||||
# Get project context for planning
|
|
||||||
ktx agent context --json
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Semantic layer:**
|
**Semantic layer:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# List sources
|
# List sources
|
||||||
ktx agent sl list --json
|
ktx sl list --json
|
||||||
ktx agent sl list --json --connection-id my-postgres
|
ktx sl list --json --connection-id my-postgres
|
||||||
|
ktx sl list --json --query "revenue"
|
||||||
|
|
||||||
# Read a source
|
# 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
|
# Run a query from a JSON file
|
||||||
ktx agent sl query --json \
|
ktx sl query --json \
|
||||||
--connection-id my-postgres \
|
--connection-id my-postgres \
|
||||||
--query-file query.json \
|
--query-file query.json \
|
||||||
--execute \
|
--execute \
|
||||||
|
|
@ -44,20 +43,10 @@ ktx agent sl query --json \
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Search knowledge pages
|
# Search knowledge pages
|
||||||
ktx agent wiki search "revenue recognition" --json --limit 10
|
ktx wiki search "revenue recognition" --json --limit 10
|
||||||
|
|
||||||
# Read a specific page
|
# Read a specific page
|
||||||
ktx agent wiki read order-status-definitions --json
|
ktx 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Setting Up Your Agent
|
## Setting Up Your Agent
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@ title: Agent Clients
|
||||||
description: Set up KTX with Claude Code, Cursor, Codex, and OpenCode.
|
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.
|
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"
|
```markdown title=".claude/skills/ktx/SKILL.md"
|
||||||
---
|
---
|
||||||
name: ktx
|
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:
|
Available commands:
|
||||||
- `ktx agent context --json --project-dir /path/to/project`
|
- `ktx status --json --project-dir /path/to/project`
|
||||||
- `ktx agent sl list --json --project-dir /path/to/project`
|
- `ktx sl list --json --project-dir /path/to/project`
|
||||||
- `ktx agent sl read '<sourceName>' --json --project-dir /path/to/project`
|
- `ktx sl list --json --project-dir /path/to/project --query '<text>'`
|
||||||
- `ktx agent sl query --json --project-dir /path/to/project --connection-id '<id>' --query-file '<path>' --execute --max-rows 100`
|
- `ktx sl read '<sourceName>' --json --project-dir /path/to/project --connection-id '<id>'`
|
||||||
- `ktx agent wiki search '<query>' --json --project-dir /path/to/project`
|
- `ktx sl query --json --project-dir /path/to/project --connection-id '<id>' --query-file '<path>' --execute --max-rows 100`
|
||||||
- `ktx agent wiki read '<pageId>' --json --project-dir /path/to/project`
|
- `ktx wiki search '<query>' --json --project-dir /path/to/project --limit 10`
|
||||||
- `ktx agent sql execute --json --project-dir /path/to/project --connection-id '<id>' --sql-file '<path>' --max-rows 100`
|
- `ktx wiki read '<pageId>' --json --project-dir /path/to/project`
|
||||||
```
|
```
|
||||||
|
|
||||||
### Workflow tips
|
### Workflow tips
|
||||||
|
|
@ -123,22 +125,19 @@ All supported agent clients call the same KTX CLI commands:
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `ktx agent context --json` | Return a compact project context summary |
|
| `ktx status --json` | Return project setup and context readiness |
|
||||||
| `ktx agent tools --json` | List available agent-facing commands |
|
| `ktx wiki search <query> --json` | Search knowledge pages |
|
||||||
| `ktx agent wiki search <query> --json` | Search knowledge pages |
|
| `ktx wiki read <key> --json` | Read a knowledge page |
|
||||||
| `ktx agent wiki read <key> --json` | Read a knowledge page |
|
| `ktx wiki write <key>` | Write or update a knowledge page |
|
||||||
| `ktx agent wiki write --json` | Write or update a knowledge page |
|
| `ktx sl list --json` | List semantic-layer sources |
|
||||||
| `ktx agent sl list --json` | List semantic layer sources |
|
| `ktx sl list --query <text> --json` | Search semantic-layer sources |
|
||||||
| `ktx agent sl read <source> --json` | Read a semantic source definition |
|
| `ktx sl read <source> --json --connection-id <id>` | Read a semantic source definition |
|
||||||
| `ktx agent sl write --json` | Write or update a semantic source |
|
| `ktx sl write <source> --connection-id <id>` | Write or update a semantic source |
|
||||||
| `ktx agent sl validate --json` | Validate semantic source definitions |
|
| `ktx sl validate <source> --connection-id <id>` | Validate semantic source definitions |
|
||||||
| `ktx agent sl query --json` | Execute a semantic layer query when semantic compute is configured |
|
| `ktx 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 |
|
|
||||||
|
|
||||||
### Security constraints
|
### 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.
|
- Secrets and credentials are never exposed in command output.
|
||||||
- Commands resolve the project from `--project-dir`, `KTX_PROJECT_DIR`, or the nearest `ktx.yaml`.
|
- 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 |
|
| 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 |
|
| 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 |
|
| 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
|
- [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
|
- [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
|
- [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
|
## CLI Reference
|
||||||
|
|
||||||
${link("/docs/cli-reference/ktx-setup", "ktx setup", "Interactive project setup")}
|
${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-sl", "ktx sl", "Semantic-layer commands")}
|
||||||
${link("/docs/cli-reference/ktx-wiki", "ktx wiki", "Knowledge page commands")}
|
${link("/docs/cli-reference/ktx-wiki", "ktx wiki", "Knowledge page commands")}
|
||||||
${link("/docs/cli-reference/ktx-connection", "ktx connection", "Connection management 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 { Command, InvalidArgumentError } from '@commander-js/extra-typings';
|
||||||
import type { KtxCliDeps, KtxCliIo, KtxCliPackageInfo } from './cli-runtime.js';
|
import type { KtxCliDeps, KtxCliIo, KtxCliPackageInfo } from './cli-runtime.js';
|
||||||
import { registerAgentCommands } from './commands/agent-commands.js';
|
|
||||||
import { registerConnectionCommands } from './commands/connection-commands.js';
|
import { registerConnectionCommands } from './commands/connection-commands.js';
|
||||||
import { registerIngestCommands } from './commands/ingest-commands.js';
|
import { registerIngestCommands } from './commands/ingest-commands.js';
|
||||||
import { registerWikiCommands } from './commands/knowledge-commands.js';
|
import { registerWikiCommands } from './commands/knowledge-commands.js';
|
||||||
|
|
@ -321,7 +320,6 @@ export function buildKtxProgram(options: BuildKtxProgramOptions): Command {
|
||||||
registerWikiCommands(program, context);
|
registerWikiCommands(program, context);
|
||||||
registerSlCommands(program, context);
|
registerSlCommands(program, context);
|
||||||
registerStatusCommands(program, context);
|
registerStatusCommands(program, context);
|
||||||
registerAgentCommands(program, context);
|
|
||||||
registerDevCommands(program, context);
|
registerDevCommands(program, context);
|
||||||
|
|
||||||
return program;
|
return program;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { createRequire } from 'node:module';
|
||||||
|
|
||||||
import type { KtxConnectionMetabaseSetupArgs } from './commands/connection-metabase-setup.js';
|
import type { KtxConnectionMetabaseSetupArgs } from './commands/connection-metabase-setup.js';
|
||||||
import type { KtxConnectionNotionArgs } from './commands/connection-notion.js';
|
import type { KtxConnectionNotionArgs } from './commands/connection-notion.js';
|
||||||
import type { KtxAgentArgs } from './agent.js';
|
|
||||||
import type { KtxConnectionArgs } from './connection.js';
|
import type { KtxConnectionArgs } from './connection.js';
|
||||||
import type { KtxDoctorArgs } from './doctor.js';
|
import type { KtxDoctorArgs } from './doctor.js';
|
||||||
import type { KtxIngestArgs } from './ingest.js';
|
import type { KtxIngestArgs } from './ingest.js';
|
||||||
|
|
@ -30,7 +29,6 @@ export interface KtxCliIo {
|
||||||
|
|
||||||
export interface KtxCliDeps {
|
export interface KtxCliDeps {
|
||||||
setup?: (args: KtxSetupArgs, io: KtxCliIo) => Promise<number>;
|
setup?: (args: KtxSetupArgs, io: KtxCliIo) => Promise<number>;
|
||||||
agent?: (args: KtxAgentArgs, io: KtxCliIo) => Promise<number>;
|
|
||||||
connection?: (args: KtxConnectionArgs, io: KtxCliIo) => Promise<number>;
|
connection?: (args: KtxConnectionArgs, io: KtxCliIo) => Promise<number>;
|
||||||
connectionNotion?: (args: KtxConnectionNotionArgs, io: KtxCliIo) => Promise<number>;
|
connectionNotion?: (args: KtxConnectionNotionArgs, io: KtxCliIo) => Promise<number>;
|
||||||
connectionMetabaseSetup?: (args: KtxConnectionMetabaseSetupArgs, io: KtxCliIo) => Promise<number>;
|
connectionMetabaseSetup?: (args: KtxConnectionMetabaseSetupArgs, io: KtxCliIo) => Promise<number>;
|
||||||
|
|
|
||||||
|
|
@ -53,15 +53,18 @@ export const slQueryCommandSchema = z.object({
|
||||||
command: z.literal('query'),
|
command: z.literal('query'),
|
||||||
projectDir: projectDirSchema,
|
projectDir: projectDirSchema,
|
||||||
connectionId: z.string().min(1).optional(),
|
connectionId: z.string().min(1).optional(),
|
||||||
query: z.object({
|
query: z
|
||||||
measures: z.array(z.string().min(1)).min(1),
|
.object({
|
||||||
dimensions: stringArraySchema,
|
measures: z.array(z.string().min(1)).min(1),
|
||||||
filters: stringArraySchema.optional(),
|
dimensions: stringArraySchema,
|
||||||
segments: stringArraySchema.optional(),
|
filters: stringArraySchema.optional(),
|
||||||
order_by: z.array(orderBySchema).optional(),
|
segments: stringArraySchema.optional(),
|
||||||
limit: z.number().int().positive().optional(),
|
order_by: z.array(orderBySchema).optional(),
|
||||||
include_empty: z.literal(true).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']),
|
format: z.enum(['json', 'sql']),
|
||||||
execute: z.boolean(),
|
execute: z.boolean(),
|
||||||
cliVersion: z.string().min(1),
|
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 { 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 { wikiWriteCommandSchema } from '../command-schemas.js';
|
||||||
import type { KtxKnowledgeArgs } from '../knowledge.js';
|
import type { KtxKnowledgeArgs } from '../knowledge.js';
|
||||||
import { profileMark } from '../startup-profile.js';
|
import { profileMark } from '../startup-profile.js';
|
||||||
|
|
@ -24,12 +29,14 @@ export function registerWikiCommands(program: Command, context: KtxCliCommandCon
|
||||||
wiki
|
wiki
|
||||||
.command('list')
|
.command('list')
|
||||||
.description('List local wiki pages')
|
.description('List local wiki pages')
|
||||||
|
.option('--json', 'Print JSON output', false)
|
||||||
.option('--user-id <id>', 'Local user id', 'local')
|
.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, {
|
await runKnowledgeArgs(context, {
|
||||||
command: 'list',
|
command: 'list',
|
||||||
projectDir: resolveCommandProjectDir(command),
|
projectDir: resolveCommandProjectDir(command),
|
||||||
userId: options.userId,
|
userId: options.userId,
|
||||||
|
json: options.json,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -37,13 +44,15 @@ export function registerWikiCommands(program: Command, context: KtxCliCommandCon
|
||||||
.command('read')
|
.command('read')
|
||||||
.description('Read one local wiki page')
|
.description('Read one local wiki page')
|
||||||
.argument('<key>', 'Wiki page key')
|
.argument('<key>', 'Wiki page key')
|
||||||
|
.option('--json', 'Print JSON output', false)
|
||||||
.option('--user-id <id>', 'Local user id', 'local')
|
.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, {
|
await runKnowledgeArgs(context, {
|
||||||
command: 'read',
|
command: 'read',
|
||||||
projectDir: resolveCommandProjectDir(command),
|
projectDir: resolveCommandProjectDir(command),
|
||||||
key,
|
key,
|
||||||
userId: options.userId,
|
userId: options.userId,
|
||||||
|
json: options.json,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -51,13 +60,17 @@ export function registerWikiCommands(program: Command, context: KtxCliCommandCon
|
||||||
.command('search')
|
.command('search')
|
||||||
.description('Search local wiki pages')
|
.description('Search local wiki pages')
|
||||||
.argument('<query>', 'Search query')
|
.argument('<query>', 'Search query')
|
||||||
|
.option('--json', 'Print JSON output', false)
|
||||||
.option('--user-id <id>', 'Local user id', 'local')
|
.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, {
|
await runKnowledgeArgs(context, {
|
||||||
command: 'search',
|
command: 'search',
|
||||||
projectDir: resolveCommandProjectDir(command),
|
projectDir: resolveCommandProjectDir(command),
|
||||||
query,
|
query,
|
||||||
userId: options.userId,
|
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')
|
sl.command('list')
|
||||||
.description('List semantic-layer sources')
|
.description('List semantic-layer sources')
|
||||||
.option('--connection-id <id>', 'KTX connection id')
|
.option('--connection-id <id>', 'KTX connection id')
|
||||||
|
.option('--query <text>', 'Search source names and descriptions')
|
||||||
.addOption(
|
.addOption(
|
||||||
new Option('--output <mode>', 'Output mode: pretty (default in TTY), plain (TSV), or json').choices([
|
new Option('--output <mode>', 'Output mode: pretty (default in TTY), plain (TSV), or json').choices([
|
||||||
'pretty',
|
'pretty',
|
||||||
|
|
@ -59,26 +60,34 @@ export function registerSlCommands(program: Command, context: KtxCliCommandConte
|
||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
.option('--json', 'Shortcut for --output=json (overrides --output)', false)
|
.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, {
|
await runSlArgs(context, {
|
||||||
command: 'list',
|
command: 'list',
|
||||||
projectDir: resolveCommandProjectDir(command),
|
projectDir: resolveCommandProjectDir(command),
|
||||||
connectionId: options.connectionId,
|
connectionId: options.connectionId,
|
||||||
|
query: options.query,
|
||||||
output: options.output,
|
output: options.output,
|
||||||
json: options.json,
|
json: options.json,
|
||||||
});
|
});
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
sl.command('read')
|
sl.command('read')
|
||||||
.description('Read a semantic-layer source')
|
.description('Read a semantic-layer source')
|
||||||
.argument('<sourceName>', 'Semantic-layer source name')
|
.argument('<sourceName>', 'Semantic-layer source name')
|
||||||
.requiredOption('--connection-id <id>', 'KTX connection id')
|
.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, {
|
await runSlArgs(context, {
|
||||||
command: 'read',
|
command: 'read',
|
||||||
projectDir: resolveCommandProjectDir(command),
|
projectDir: resolveCommandProjectDir(command),
|
||||||
connectionId: options.connectionId,
|
connectionId: options.connectionId,
|
||||||
sourceName,
|
sourceName,
|
||||||
|
json: options.json,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -113,6 +122,7 @@ export function registerSlCommands(program: Command, context: KtxCliCommandConte
|
||||||
sl.command('query')
|
sl.command('query')
|
||||||
.description('Compile or execute a semantic-layer query')
|
.description('Compile or execute a semantic-layer query')
|
||||||
.option('--connection-id <id>', 'KTX connection id')
|
.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('--measure <measure>', 'Measure to query; repeatable', collectOption, [])
|
||||||
.option('--dimension <dimension>', 'Dimension to include; repeatable', collectOption, [])
|
.option('--dimension <dimension>', 'Dimension to include; repeatable', collectOption, [])
|
||||||
.option('--filter <filter>', 'Filter expression; 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('--no-input', 'Disable interactive managed runtime installation')
|
||||||
.option('--max-rows <n>', 'Maximum rows to return when executing', parsePositiveIntegerOption)
|
.option('--max-rows <n>', 'Maximum rows to return when executing', parsePositiveIntegerOption)
|
||||||
.action(async (options, command) => {
|
.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');
|
throw new Error('sl query requires at least one --measure');
|
||||||
}
|
}
|
||||||
const args = slQueryCommandSchema.parse({
|
const args = slQueryCommandSchema.parse({
|
||||||
command: 'query',
|
command: 'query',
|
||||||
projectDir: resolveCommandProjectDir(command),
|
projectDir: resolveCommandProjectDir(command),
|
||||||
connectionId: options.connectionId,
|
connectionId: options.connectionId,
|
||||||
query: {
|
...(options.queryFile
|
||||||
measures: options.measure,
|
? { queryFile: options.queryFile }
|
||||||
dimensions: options.dimension,
|
: {
|
||||||
...(options.filter.length > 0 ? { filters: options.filter } : {}),
|
query: {
|
||||||
...(options.segment.length > 0 ? { segments: options.segment } : {}),
|
measures: options.measure,
|
||||||
...(options.orderBy.length > 0 ? { order_by: options.orderBy } : {}),
|
dimensions: options.dimension,
|
||||||
...(options.limit !== undefined ? { limit: options.limit } : {}),
|
...(options.filter.length > 0 ? { filters: options.filter } : {}),
|
||||||
...(options.includeEmpty === true ? { include_empty: true } : {}),
|
...(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,
|
format: options.format,
|
||||||
execute: options.execute === true,
|
execute: options.execute === true,
|
||||||
cliVersion: context.packageInfo.version,
|
cliVersion: context.packageInfo.version,
|
||||||
|
|
|
||||||
|
|
@ -73,26 +73,27 @@ describe('standalone local warehouse example', () => {
|
||||||
const projectDir = await copyExampleProject(tempDir);
|
const projectDir = await copyExampleProject(tempDir);
|
||||||
const sourceDir = join(projectDir, 'source');
|
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(knowledgeList).toMatchObject({ code: 0, stderr: '' });
|
||||||
expect(parseJsonOutput<{ results: Array<{ key: string; summary: string }> }>(knowledgeList.stdout).results).toContainEqual(
|
expect(
|
||||||
expect.objectContaining({ key: 'revenue', summary: 'Paid order value after refunds' }),
|
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(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.',
|
'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(slList).toMatchObject({ code: 0, stderr: '' });
|
||||||
expect(parseJsonOutput<{ sources: Array<{ connectionId: string; name: string; columnCount: number }> }>(slList.stdout).sources).toContainEqual(
|
expect(
|
||||||
expect.objectContaining({ connectionId: 'warehouse', name: 'orders', columnCount: 3 }),
|
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([
|
const slRead = await runBuiltCli([
|
||||||
'agent',
|
|
||||||
'sl',
|
'sl',
|
||||||
'read',
|
'read',
|
||||||
'orders',
|
'orders',
|
||||||
|
|
@ -103,7 +104,7 @@ describe('standalone local warehouse example', () => {
|
||||||
projectDir,
|
projectDir,
|
||||||
]);
|
]);
|
||||||
expect(slRead).toMatchObject({ code: 0, stderr: '' });
|
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([
|
const ingest = await runBuiltCli([
|
||||||
'ingest',
|
'ingest',
|
||||||
|
|
|
||||||
|
|
@ -1141,136 +1141,28 @@ describe('runKtxCli', () => {
|
||||||
expect(setupIo.stderr()).toContain('Choose only one Historic SQL action');
|
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 () => {
|
it('rejects the removed hidden agent command', async () => {
|
||||||
const helpIo = makeIo();
|
const io = makeIo();
|
||||||
const toolsIo = makeIo();
|
|
||||||
const agent = vi.fn(async () => 0);
|
|
||||||
|
|
||||||
await expect(runKtxCli(['agent', '--help'], helpIo.io, { agent })).resolves.toBe(0);
|
await expect(runKtxCli(['agent'], io.io)).resolves.toBe(1);
|
||||||
await expect(
|
|
||||||
runKtxCli(['--project-dir', tempDir, 'agent', 'tools', '--json'], toolsIo.io, { agent }),
|
|
||||||
).resolves.toBe(0);
|
|
||||||
|
|
||||||
expect(helpIo.stdout()).toContain('Usage: ktx agent');
|
expect(io.stderr()).toContain("unknown command 'agent'");
|
||||||
expect(toolsIo.stderr()).toBe('');
|
expect(io.stdout()).toBe('');
|
||||||
expect(agent).toHaveBeenCalledWith({ command: 'tools', projectDir: tempDir, json: true }, toolsIo.io);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('dispatches full hidden agent commands without exposing agent in root help', async () => {
|
it('routes public SL query files with managed runtime policies', 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 () => {
|
|
||||||
const autoIo = makeIo();
|
const autoIo = makeIo();
|
||||||
const neverIo = makeIo();
|
const neverIo = makeIo();
|
||||||
const conflictIo = makeIo();
|
const conflictIo = makeIo();
|
||||||
const agent = vi.fn(async () => 0);
|
const sl = vi.fn(async () => 0);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
runKtxCli(
|
runKtxCli(
|
||||||
[
|
[
|
||||||
'--project-dir',
|
'--project-dir',
|
||||||
tempDir,
|
tempDir,
|
||||||
'agent',
|
|
||||||
'sl',
|
'sl',
|
||||||
'query',
|
'query',
|
||||||
'--json',
|
|
||||||
'--connection-id',
|
'--connection-id',
|
||||||
'warehouse',
|
'warehouse',
|
||||||
'--query-file',
|
'--query-file',
|
||||||
|
|
@ -1278,7 +1170,7 @@ describe('runKtxCli', () => {
|
||||||
'--yes',
|
'--yes',
|
||||||
],
|
],
|
||||||
autoIo.io,
|
autoIo.io,
|
||||||
{ agent },
|
{ sl },
|
||||||
),
|
),
|
||||||
).resolves.toBe(0);
|
).resolves.toBe(0);
|
||||||
|
|
||||||
|
|
@ -1287,10 +1179,8 @@ describe('runKtxCli', () => {
|
||||||
[
|
[
|
||||||
'--project-dir',
|
'--project-dir',
|
||||||
tempDir,
|
tempDir,
|
||||||
'agent',
|
|
||||||
'sl',
|
'sl',
|
||||||
'query',
|
'query',
|
||||||
'--json',
|
|
||||||
'--connection-id',
|
'--connection-id',
|
||||||
'warehouse',
|
'warehouse',
|
||||||
'--query-file',
|
'--query-file',
|
||||||
|
|
@ -1298,7 +1188,7 @@ describe('runKtxCli', () => {
|
||||||
'--no-input',
|
'--no-input',
|
||||||
],
|
],
|
||||||
neverIo.io,
|
neverIo.io,
|
||||||
{ agent },
|
{ sl },
|
||||||
),
|
),
|
||||||
).resolves.toBe(0);
|
).resolves.toBe(0);
|
||||||
|
|
||||||
|
|
@ -1307,10 +1197,8 @@ describe('runKtxCli', () => {
|
||||||
[
|
[
|
||||||
'--project-dir',
|
'--project-dir',
|
||||||
tempDir,
|
tempDir,
|
||||||
'agent',
|
|
||||||
'sl',
|
'sl',
|
||||||
'query',
|
'query',
|
||||||
'--json',
|
|
||||||
'--connection-id',
|
'--connection-id',
|
||||||
'warehouse',
|
'warehouse',
|
||||||
'--query-file',
|
'--query-file',
|
||||||
|
|
@ -1319,33 +1207,33 @@ describe('runKtxCli', () => {
|
||||||
'--no-input',
|
'--no-input',
|
||||||
],
|
],
|
||||||
conflictIo.io,
|
conflictIo.io,
|
||||||
{ agent },
|
{ sl },
|
||||||
),
|
),
|
||||||
).resolves.toBe(1);
|
).resolves.toBe(1);
|
||||||
|
|
||||||
expect(agent).toHaveBeenNthCalledWith(
|
expect(sl).toHaveBeenNthCalledWith(
|
||||||
1,
|
1,
|
||||||
{
|
{
|
||||||
command: 'sl-query',
|
command: 'query',
|
||||||
projectDir: tempDir,
|
projectDir: tempDir,
|
||||||
json: true,
|
|
||||||
connectionId: 'warehouse',
|
connectionId: 'warehouse',
|
||||||
queryFile: '/tmp/query.json',
|
queryFile: '/tmp/query.json',
|
||||||
execute: false,
|
execute: false,
|
||||||
|
format: 'json',
|
||||||
cliVersion: '0.0.0-private',
|
cliVersion: '0.0.0-private',
|
||||||
runtimeInstallPolicy: 'auto',
|
runtimeInstallPolicy: 'auto',
|
||||||
},
|
},
|
||||||
autoIo.io,
|
autoIo.io,
|
||||||
);
|
);
|
||||||
expect(agent).toHaveBeenNthCalledWith(
|
expect(sl).toHaveBeenNthCalledWith(
|
||||||
2,
|
2,
|
||||||
{
|
{
|
||||||
command: 'sl-query',
|
command: 'query',
|
||||||
projectDir: tempDir,
|
projectDir: tempDir,
|
||||||
json: true,
|
|
||||||
connectionId: 'warehouse',
|
connectionId: 'warehouse',
|
||||||
queryFile: '/tmp/query.json',
|
queryFile: '/tmp/query.json',
|
||||||
execute: false,
|
execute: false,
|
||||||
|
format: 'json',
|
||||||
cliVersion: '0.0.0-private',
|
cliVersion: '0.0.0-private',
|
||||||
runtimeInstallPolicy: 'never',
|
runtimeInstallPolicy: 'never',
|
||||||
},
|
},
|
||||||
|
|
@ -1354,112 +1242,6 @@ describe('runKtxCli', () => {
|
||||||
expect(conflictIo.stderr()).toContain('Choose only one runtime install mode: --yes or --no-input');
|
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 () => {
|
it('dispatches public connection subcommands through the existing connection implementation', async () => {
|
||||||
const tempDir = await mkdtemp(join(tmpdir(), 'ktx-connection-dispatch-'));
|
const tempDir = await mkdtemp(join(tmpdir(), 'ktx-connection-dispatch-'));
|
||||||
const connection = vi.fn(async () => 0);
|
const connection = vi.fn(async () => 0);
|
||||||
|
|
|
||||||
|
|
@ -9,17 +9,6 @@ export {
|
||||||
type KtxCliIo,
|
type KtxCliIo,
|
||||||
type KtxCliPackageInfo,
|
type KtxCliPackageInfo,
|
||||||
} from './cli-runtime.js';
|
} 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 { runKtxSetup, type KtxSetupArgs, type KtxSetupStatus } from './setup.js';
|
||||||
export type {
|
export type {
|
||||||
KtxSetupDatabaseDriver,
|
KtxSetupDatabaseDriver,
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,65 @@ describe('runKtxKnowledge', () => {
|
||||||
expect(searchIo.stdout()).toContain('metrics-revenue');
|
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 () => {
|
it('rejects slash-delimited write keys with a flat-key suggestion', async () => {
|
||||||
const projectDir = join(tempDir, 'project');
|
const projectDir = join(tempDir, 'project');
|
||||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,12 @@ import {
|
||||||
searchLocalKnowledgePages,
|
searchLocalKnowledgePages,
|
||||||
writeLocalKnowledgePage,
|
writeLocalKnowledgePage,
|
||||||
} from '@ktx/context/wiki';
|
} from '@ktx/context/wiki';
|
||||||
|
import { writeJsonResult } from './io/print-list.js';
|
||||||
|
|
||||||
export type KtxKnowledgeArgs =
|
export type KtxKnowledgeArgs =
|
||||||
| { command: 'list'; projectDir: string; userId: string }
|
| { command: 'list'; projectDir: string; userId: string; json?: boolean }
|
||||||
| { command: 'read'; projectDir: string; key: string; userId: string }
|
| { command: 'read'; projectDir: string; key: string; userId: string; json?: boolean }
|
||||||
| { command: 'search'; projectDir: string; query: string; userId: string }
|
| { command: 'search'; projectDir: string; query: string; userId: string; json?: boolean; limit?: number }
|
||||||
| {
|
| {
|
||||||
command: 'write';
|
command: 'write';
|
||||||
projectDir: string;
|
projectDir: string;
|
||||||
|
|
@ -61,6 +62,14 @@ export async function runKtxKnowledge(
|
||||||
const project = await loadKtxProject({ projectDir: args.projectDir });
|
const project = await loadKtxProject({ projectDir: args.projectDir });
|
||||||
if (args.command === 'list') {
|
if (args.command === 'list') {
|
||||||
const pages = await listLocalKnowledgePages(project, { userId: args.userId });
|
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) {
|
for (const page of pages) {
|
||||||
io.stdout.write(`${page.scope}\t${page.key}\t${page.summary}\n`);
|
io.stdout.write(`${page.scope}\t${page.key}\t${page.summary}\n`);
|
||||||
}
|
}
|
||||||
|
|
@ -71,6 +80,14 @@ export async function runKtxKnowledge(
|
||||||
if (!page) {
|
if (!page) {
|
||||||
throw new Error(`Knowledge page "${args.key}" was not found`);
|
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(`# ${page.key}\n\n`);
|
||||||
io.stdout.write(`Scope: ${page.scope}\n`);
|
io.stdout.write(`Scope: ${page.scope}\n`);
|
||||||
io.stdout.write(`Summary: ${page.summary}\n\n`);
|
io.stdout.write(`Summary: ${page.summary}\n\n`);
|
||||||
|
|
@ -82,7 +99,16 @@ export async function runKtxKnowledge(
|
||||||
query: args.query,
|
query: args.query,
|
||||||
userId: args.userId,
|
userId: args.userId,
|
||||||
embeddingService: wikiSearchEmbeddingService(project, deps),
|
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) {
|
if (results.length === 0) {
|
||||||
const pages = await listLocalKnowledgePages(project, { userId: args.userId });
|
const pages = await listLocalKnowledgePages(project, { userId: args.userId });
|
||||||
if (pages.length === 0) {
|
if (pages.length === 0) {
|
||||||
|
|
|
||||||
|
|
@ -25,12 +25,8 @@ describe('KTX demo next steps', () => {
|
||||||
it('uses supported final public commands', () => {
|
it('uses supported final public commands', () => {
|
||||||
expect(KTX_NEXT_STEP_COMMANDS).toEqual([
|
expect(KTX_NEXT_STEP_COMMANDS).toEqual([
|
||||||
{
|
{
|
||||||
command: 'ktx agent context --json',
|
command: 'ktx status --json',
|
||||||
description: 'Verify the project context your agent can read',
|
description: 'Verify project setup and context readiness',
|
||||||
},
|
|
||||||
{
|
|
||||||
command: 'ktx agent tools --json',
|
|
||||||
description: 'List direct CLI tools available to agents',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
command: 'ktx sl list',
|
command: 'ktx sl list',
|
||||||
|
|
@ -46,8 +42,8 @@ describe('KTX demo next steps', () => {
|
||||||
it('uses only the direct CLI route for agent verification', () => {
|
it('uses only the direct CLI route for agent verification', () => {
|
||||||
const commands = KTX_NEXT_STEP_COMMANDS.map((step) => step.command);
|
const commands = KTX_NEXT_STEP_COMMANDS.map((step) => step.command);
|
||||||
|
|
||||||
expect(commands).toContain('ktx agent context --json');
|
expect(commands).not.toContain('ktx agent context --json');
|
||||||
expect(commands).toContain('ktx agent tools --json');
|
expect(commands).toContain('ktx status --json');
|
||||||
expect(commands).not.toContain('ktx serve --mcp stdio --user-id local');
|
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', () => {
|
it('does not advertise removed Commander migration commands', () => {
|
||||||
const rendered = formatNextStepLines().join('\n');
|
const rendered = formatNextStepLines().join('\n');
|
||||||
|
|
||||||
expect(rendered).toContain('ktx agent tools --json');
|
expect(rendered).toContain('ktx status --json');
|
||||||
expect(rendered).toContain('ktx agent context --json');
|
expect(rendered).not.toContain('ktx agent');
|
||||||
expect(rendered).toContain('ktx sl list');
|
expect(rendered).toContain('ktx sl list');
|
||||||
expect(rendered).toContain('ktx wiki list');
|
expect(rendered).toContain('ktx wiki list');
|
||||||
|
|
||||||
|
|
@ -109,7 +105,8 @@ describe('KTX demo next steps', () => {
|
||||||
}).join('\n');
|
}).join('\n');
|
||||||
|
|
||||||
expect(rendered).toContain('KTX context is ready for agents.');
|
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('ktx serve --mcp stdio --user-id local');
|
||||||
expect(rendered).not.toContain('Build KTX context next.');
|
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 = [
|
export const KTX_NEXT_STEP_DIRECT_COMMANDS = [
|
||||||
{
|
{
|
||||||
command: 'ktx agent context --json',
|
command: 'ktx status --json',
|
||||||
description: 'Verify the project context your agent can read',
|
description: 'Verify project setup and context readiness',
|
||||||
},
|
|
||||||
{
|
|
||||||
command: 'ktx agent tools --json',
|
|
||||||
description: 'List direct CLI tools available to agents',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
command: 'ktx sl list',
|
command: 'ktx sl list',
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,7 @@ describe('project directory defaults', () => {
|
||||||
const ingest = vi.fn(async () => 0);
|
const ingest = vi.fn(async () => 0);
|
||||||
const scan = vi.fn(async () => 0);
|
const scan = vi.fn(async () => 0);
|
||||||
const setup = vi.fn(async () => 0);
|
const setup = vi.fn(async () => 0);
|
||||||
const agent = vi.fn(async () => 0);
|
const deps: KtxCliDeps = { connection, doctor, ingest, scan, setup };
|
||||||
const deps: KtxCliDeps = { agent, connection, doctor, ingest, scan, setup };
|
|
||||||
|
|
||||||
const cases: Array<{
|
const cases: Array<{
|
||||||
argv: string[];
|
argv: string[];
|
||||||
|
|
@ -74,12 +73,6 @@ describe('project directory defaults', () => {
|
||||||
expected: { command: 'run', projectDir: '/tmp/ktx-env-project', connectionId: 'warehouse' },
|
expected: { command: 'run', projectDir: '/tmp/ktx-env-project', connectionId: 'warehouse' },
|
||||||
expectedStderr: 'Project: /tmp/ktx-env-project\n',
|
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) {
|
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');
|
const skill = await readFile(join(tempDir, '.agents/skills/ktx/SKILL.md'), 'utf-8');
|
||||||
expect(skill).toContain(`--project-dir ${tempDir}`);
|
expect(skill).toContain(`--project-dir ${tempDir}`);
|
||||||
expect(skill).toContain('must not print secrets');
|
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({
|
expect(await readKtxAgentInstallManifest(tempDir)).toMatchObject({
|
||||||
version: 1,
|
version: 1,
|
||||||
projectDir: tempDir,
|
projectDir: tempDir,
|
||||||
|
|
@ -115,8 +118,9 @@ describe('setup agents', () => {
|
||||||
|
|
||||||
const skill = await readFile(join(tempDir, '.agents/skills/ktx/SKILL.md'), 'utf-8');
|
const skill = await readFile(join(tempDir, '.agents/skills/ktx/SKILL.md'), 'utf-8');
|
||||||
expect(skill).not.toContain('`ktx agent');
|
expect(skill).not.toContain('`ktx agent');
|
||||||
expect(skill).toContain('agent context --json');
|
expect(skill).toContain('status --json');
|
||||||
expect(skill).toContain('agent sql execute');
|
expect(skill).toContain('sl query');
|
||||||
|
expect(skill).not.toContain('sql execute');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('removes only manifest-listed files', async () => {
|
it('removes only manifest-listed files', async () => {
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,7 @@ function cliInstructionContent(input: { projectDir: string; launcher: KtxCliLaun
|
||||||
return [
|
return [
|
||||||
'---',
|
'---',
|
||||||
'name: ktx',
|
'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',
|
'# KTX Local Context',
|
||||||
|
|
@ -137,11 +137,11 @@ function cliInstructionContent(input: { projectDir: string; launcher: KtxCliLaun
|
||||||
'',
|
'',
|
||||||
'Available commands:',
|
'Available commands:',
|
||||||
'',
|
'',
|
||||||
`- \`${ktxCommandLine(input.launcher, ['agent', 'context', ...projectDirArgs])}\``,
|
`- \`${ktxCommandLine(input.launcher, ['status', ...projectDirArgs])}\``,
|
||||||
`- \`${ktxCommandLine(input.launcher, ['agent', 'sl', 'list', ...projectDirArgs])}\``,
|
`- \`${ktxCommandLine(input.launcher, ['sl', 'list', ...projectDirArgs])}\``,
|
||||||
`- \`${ktxCommandLine(input.launcher, ['agent', 'sl', 'read', '<sourceName>', ...projectDirArgs])}\``,
|
`- \`${ktxCommandLine(input.launcher, ['sl', 'list', ...projectDirArgs, '--query', '<text>'])}\``,
|
||||||
|
`- \`${ktxCommandLine(input.launcher, ['sl', 'read', '<sourceName>', ...projectDirArgs, '--connection-id', '<id>'])}\``,
|
||||||
`- \`${ktxCommandLine(input.launcher, [
|
`- \`${ktxCommandLine(input.launcher, [
|
||||||
'agent',
|
|
||||||
'sl',
|
'sl',
|
||||||
'query',
|
'query',
|
||||||
...projectDirArgs,
|
...projectDirArgs,
|
||||||
|
|
@ -153,29 +153,17 @@ function cliInstructionContent(input: { projectDir: string; launcher: KtxCliLaun
|
||||||
'--max-rows',
|
'--max-rows',
|
||||||
'100',
|
'100',
|
||||||
])}\``,
|
])}\``,
|
||||||
`- \`${ktxCommandLine(input.launcher, ['agent', 'wiki', 'search', '<query>', ...projectDirArgs])}\``,
|
`- \`${ktxCommandLine(input.launcher, ['wiki', 'search', '<query>', ...projectDirArgs, '--limit', '10'])}\``,
|
||||||
`- \`${ktxCommandLine(input.launcher, ['agent', 'wiki', 'read', '<pageId>', ...projectDirArgs])}\``,
|
`- \`${ktxCommandLine(input.launcher, ['wiki', 'read', '<pageId>', ...projectDirArgs])}\``,
|
||||||
`- \`${ktxCommandLine(input.launcher, [
|
|
||||||
'agent',
|
|
||||||
'sql',
|
|
||||||
'execute',
|
|
||||||
...projectDirArgs,
|
|
||||||
'--connection-id',
|
|
||||||
'<id>',
|
|
||||||
'--sql-file',
|
|
||||||
'<path>',
|
|
||||||
'--max-rows',
|
|
||||||
'100',
|
|
||||||
])}\``,
|
|
||||||
'',
|
'',
|
||||||
'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');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function ruleInstructionContent(input: { projectDir: string }): string {
|
function ruleInstructionContent(input: { projectDir: string }): string {
|
||||||
return [
|
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.',
|
'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');
|
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 () => {
|
it('fails validation when a table-backed source declares columns absent from a matching warehouse manifest', async () => {
|
||||||
const projectDir = join(tempDir, 'project');
|
const projectDir = join(tempDir, 'project');
|
||||||
const project = await initKtxProject({ projectDir, projectName: 'warehouse' });
|
const project = await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||||
|
|
@ -191,6 +256,73 @@ joins: []
|
||||||
expect(stderr.write).not.toHaveBeenCalled();
|
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 () => {
|
it('creates default sl query compute through the managed runtime helper', async () => {
|
||||||
const projectDir = join(tempDir, 'project');
|
const projectDir = join(tempDir, 'project');
|
||||||
const project = await initKtxProject({ projectDir, projectName: 'warehouse' });
|
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 { createDefaultLocalQueryExecutor, type KtxSqlQueryExecutorPort } from '@ktx/context/connections';
|
||||||
|
import {
|
||||||
|
createLocalKtxEmbeddingProviderFromConfig,
|
||||||
|
KtxIngestEmbeddingPortAdapter,
|
||||||
|
type KtxEmbeddingPort,
|
||||||
|
} from '@ktx/context';
|
||||||
import type { KtxSemanticLayerComputePort } from '@ktx/context/daemon';
|
import type { KtxSemanticLayerComputePort } from '@ktx/context/daemon';
|
||||||
import { loadKtxProject, type KtxLocalProject } from '@ktx/context/project';
|
import { loadKtxProject, type KtxLocalProject } from '@ktx/context/project';
|
||||||
import {
|
import {
|
||||||
compileLocalSlQuery,
|
compileLocalSlQuery,
|
||||||
listLocalSlSources,
|
listLocalSlSources,
|
||||||
readLocalSlSource,
|
readLocalSlSource,
|
||||||
|
searchLocalSlSources,
|
||||||
validateLocalSlSource,
|
validateLocalSlSource,
|
||||||
writeLocalSlSource,
|
writeLocalSlSource,
|
||||||
type SemanticLayerQueryInput,
|
type SemanticLayerQueryInput,
|
||||||
} from '@ktx/context/sl';
|
} from '@ktx/context/sl';
|
||||||
|
import { writeJsonResult } from './io/print-list.js';
|
||||||
import {
|
import {
|
||||||
createManagedPythonSemanticLayerComputePort,
|
createManagedPythonSemanticLayerComputePort,
|
||||||
type KtxManagedPythonInstallPolicy,
|
type KtxManagedPythonInstallPolicy,
|
||||||
|
|
@ -20,15 +28,16 @@ profileMark('module:sl');
|
||||||
type SlQueryFormat = 'json' | 'sql';
|
type SlQueryFormat = 'json' | 'sql';
|
||||||
|
|
||||||
export type KtxSlArgs =
|
export type KtxSlArgs =
|
||||||
| { command: 'list'; projectDir: string; connectionId?: string; output?: string; json?: boolean }
|
| { command: 'list'; projectDir: string; connectionId?: string; query?: string; output?: string; json?: boolean }
|
||||||
| { command: 'read'; projectDir: string; connectionId: string; sourceName: string }
|
| { command: 'read'; projectDir: string; connectionId: string; sourceName: string; json?: boolean }
|
||||||
| { command: 'validate'; projectDir: string; connectionId: string; sourceName: string }
|
| { command: 'validate'; projectDir: string; connectionId: string; sourceName: string }
|
||||||
| { command: 'write'; projectDir: string; connectionId: string; sourceName: string; yaml: string }
|
| { command: 'write'; projectDir: string; connectionId: string; sourceName: string; yaml: string }
|
||||||
| {
|
| {
|
||||||
command: 'query';
|
command: 'query';
|
||||||
projectDir: string;
|
projectDir: string;
|
||||||
connectionId?: string;
|
connectionId?: string;
|
||||||
query: SemanticLayerQueryInput;
|
query?: SemanticLayerQueryInput;
|
||||||
|
queryFile?: string;
|
||||||
format: SlQueryFormat;
|
format: SlQueryFormat;
|
||||||
execute: boolean;
|
execute: boolean;
|
||||||
maxRows?: number;
|
maxRows?: number;
|
||||||
|
|
@ -43,6 +52,8 @@ interface KtxSlIo {
|
||||||
|
|
||||||
interface KtxSlDeps {
|
interface KtxSlDeps {
|
||||||
loadProject?: typeof loadKtxProject;
|
loadProject?: typeof loadKtxProject;
|
||||||
|
embeddingService?: KtxEmbeddingPort | null;
|
||||||
|
createEmbeddingProvider?: typeof createLocalKtxEmbeddingProviderFromConfig;
|
||||||
createSemanticLayerCompute?: () => KtxSemanticLayerComputePort;
|
createSemanticLayerCompute?: () => KtxSemanticLayerComputePort;
|
||||||
createManagedSemanticLayerCompute?: (options: {
|
createManagedSemanticLayerCompute?: (options: {
|
||||||
cliVersion: string;
|
cliVersion: string;
|
||||||
|
|
@ -52,11 +63,35 @@ interface KtxSlDeps {
|
||||||
createQueryExecutor?: () => KtxSqlQueryExecutorPort;
|
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> {
|
export async function runKtxSl(args: KtxSlArgs, io: KtxSlIo = process, deps: KtxSlDeps = {}): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const project = await (deps.loadProject ?? loadKtxProject)({ projectDir: args.projectDir });
|
const project = await (deps.loadProject ?? loadKtxProject)({ projectDir: args.projectDir });
|
||||||
if (args.command === 'list') {
|
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 { resolveOutputMode } = await import('./io/mode.js');
|
||||||
const { printList } = await import('./io/print-list.js');
|
const { printList } = await import('./io/print-list.js');
|
||||||
const mode = resolveOutputMode({ explicit: args.output, json: args.json, io });
|
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) {
|
if (!source) {
|
||||||
throw new Error(`Semantic-layer source "${args.connectionId}/${args.sourceName}" was not found`);
|
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);
|
io.stdout.write(source.yaml);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
@ -108,6 +151,10 @@ export async function runKtxSl(args: KtxSlArgs, io: KtxSlIo = process, deps: Ktx
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (args.command === 'query') {
|
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
|
const compute = deps.createSemanticLayerCompute
|
||||||
? deps.createSemanticLayerCompute()
|
? deps.createSemanticLayerCompute()
|
||||||
: await (deps.createManagedSemanticLayerCompute ?? createManagedPythonSemanticLayerComputePort)({
|
: 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 queryExecutor = args.execute ? (deps.createQueryExecutor ?? createDefaultLocalQueryExecutor)() : undefined;
|
||||||
const result = await compileLocalSlQuery(project as KtxLocalProject, {
|
const result = await compileLocalSlQuery(project as KtxLocalProject, {
|
||||||
connectionId: args.connectionId,
|
connectionId: args.connectionId,
|
||||||
query: args.query,
|
query,
|
||||||
compute,
|
compute,
|
||||||
execute: args.execute,
|
execute: args.execute,
|
||||||
maxRows: args.maxRows,
|
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 () => {
|
it('rejects the removed agent command through the built binary', async () => {
|
||||||
const projectDir = join(tempDir, 'missing-search-project');
|
const result = await runBuiltCli(['agent']);
|
||||||
await mkdir(projectDir, { recursive: true });
|
|
||||||
|
|
||||||
const result = await runBuiltCli([
|
|
||||||
'agent',
|
|
||||||
'sl',
|
|
||||||
'list',
|
|
||||||
'--json',
|
|
||||||
'--query',
|
|
||||||
'revenue',
|
|
||||||
'--project-dir',
|
|
||||||
projectDir,
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(result.code).toBe(1);
|
expect(result.code).toBe(1);
|
||||||
expect(result.stdout).toBe('');
|
expect(result.stdout).toBe('');
|
||||||
const errorJson = parseJsonOutput<{
|
expect(result.stderr).toContain("unknown command 'agent'");
|
||||||
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}`,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('runs doctor setup through the built binary', async () => {
|
it('runs doctor setup through the built binary', async () => {
|
||||||
const result = await runBuiltCli(['status', '--no-input']);
|
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('Node 22+');
|
||||||
expect(result.stdout).toContain('Workspace-local CLI');
|
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);
|
expect([0, 1]).toContain(result.code);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,8 @@ describe('PGlite hybrid search runtime boundary', () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const productionRoutingFiles = [
|
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/mcp/local-project-ports.ts',
|
||||||
'packages/context/src/wiki/local-knowledge.ts',
|
'packages/context/src/wiki/local-knowledge.ts',
|
||||||
'packages/context/src/ingest/context-evidence/sqlite-context-evidence-store.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');
|
const servingAgents = await readText('docs-site/content/docs/guides/serving-agents.mdx');
|
||||||
|
|
||||||
for (const command of [
|
for (const command of [
|
||||||
'ktx agent tools --json',
|
'ktx status --json',
|
||||||
'ktx agent context --json',
|
'ktx sl list --json',
|
||||||
'ktx agent sl list --json',
|
'ktx sl read orders --json',
|
||||||
'ktx agent sl read orders --json',
|
'ktx sl query --json',
|
||||||
'ktx agent sl query --json',
|
'ktx wiki search "revenue recognition" --json',
|
||||||
'ktx agent wiki search "revenue recognition" --json',
|
'ktx wiki read order-status-definitions --json',
|
||||||
'ktx agent wiki read order-status-definitions --json',
|
|
||||||
'ktx agent sql execute --json',
|
|
||||||
]) {
|
]) {
|
||||||
assert.match(servingAgents, new RegExp(command.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
assert.match(servingAgents, new RegExp(command.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -557,12 +557,6 @@ function parseJsonResultWithExitCode(label, result, expectedCode) {
|
||||||
return JSON.parse(result.stdout);
|
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) {
|
function requireIncludes(values, expected, label) {
|
||||||
assert.ok(Array.isArray(values), label + ' must be an array');
|
assert.ok(Array.isArray(values), label + ' must be an array');
|
||||||
assert.ok(values.includes(expected), label + ' did not include ' + expected + ': ' + values.join(', '));
|
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);
|
assert.equal(runtimeStatusBefore.layout.runtimeRoot, process.env.KTX_RUNTIME_ROOT);
|
||||||
process.stdout.write('ktx managed runtime starts missing in isolated root\\n');
|
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', [
|
const init = await run('pnpm', [
|
||||||
'exec',
|
'exec',
|
||||||
'ktx',
|
'ktx',
|
||||||
|
|
@ -671,28 +641,6 @@ try {
|
||||||
'--skip-agents',
|
'--skip-agents',
|
||||||
]);
|
]);
|
||||||
requireProjectStderr('ktx setup empty project', emptyInit, emptyProjectDir);
|
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(
|
await writeFile(
|
||||||
join(projectDir, 'ktx.yaml'),
|
join(projectDir, 'ktx.yaml'),
|
||||||
[
|
[
|
||||||
|
|
@ -737,10 +685,9 @@ try {
|
||||||
'utf-8',
|
'utf-8',
|
||||||
);
|
);
|
||||||
|
|
||||||
const agentWikiSearch = await run('pnpm', [
|
const wikiSearch = await run('pnpm', [
|
||||||
'exec',
|
'exec',
|
||||||
'ktx',
|
'ktx',
|
||||||
'agent',
|
|
||||||
'wiki',
|
'wiki',
|
||||||
'search',
|
'search',
|
||||||
'revenue',
|
'revenue',
|
||||||
|
|
@ -750,40 +697,17 @@ try {
|
||||||
'--project-dir',
|
'--project-dir',
|
||||||
projectDir,
|
projectDir,
|
||||||
]);
|
]);
|
||||||
const agentWikiSearchJson = parseJsonResult('ktx agent wiki search', agentWikiSearch);
|
const wikiSearchJson = parseJsonResult('ktx wiki search', wikiSearch);
|
||||||
assert.equal(agentWikiSearchJson.totalFound, 1);
|
assert.equal(wikiSearchJson.kind, 'list');
|
||||||
assert.equal(agentWikiSearchJson.results[0].key, 'revenue');
|
assert.equal(wikiSearchJson.data.items.length, 1);
|
||||||
assert.equal(agentWikiSearchJson.results[0].path, 'knowledge/global/revenue.md');
|
assert.equal(wikiSearchJson.data.items[0].key, 'revenue');
|
||||||
assert.equal(typeof agentWikiSearchJson.results[0].score, 'number');
|
assert.equal(wikiSearchJson.data.items[0].path, 'knowledge/global/revenue.md');
|
||||||
requireIncludes(agentWikiSearchJson.results[0].matchReasons, 'lexical', 'agent wiki search match reasons');
|
assert.equal(typeof wikiSearchJson.data.items[0].score, 'number');
|
||||||
process.stdout.write('ktx agent wiki search hybrid metadata verified\\n');
|
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'));
|
await access(join(projectDir, '.ktx', 'db.sqlite'));
|
||||||
process.stdout.write('SQLite knowledge index: ' + join(projectDir, '.ktx', 'db.sqlite') + '\\n');
|
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 = [
|
const slYaml = [
|
||||||
'name: orders',
|
'name: orders',
|
||||||
'table: orders',
|
'table: orders',
|
||||||
|
|
@ -804,10 +728,9 @@ try {
|
||||||
await mkdir(join(projectDir, 'semantic-layer', 'warehouse'), { recursive: true });
|
await mkdir(join(projectDir, 'semantic-layer', 'warehouse'), { recursive: true });
|
||||||
await writeFile(join(projectDir, 'semantic-layer', 'warehouse', 'orders.yaml'), slYaml, 'utf-8');
|
await writeFile(join(projectDir, 'semantic-layer', 'warehouse', 'orders.yaml'), slYaml, 'utf-8');
|
||||||
|
|
||||||
const agentSlSearch = await run('pnpm', [
|
const slSearch = await run('pnpm', [
|
||||||
'exec',
|
'exec',
|
||||||
'ktx',
|
'ktx',
|
||||||
'agent',
|
|
||||||
'sl',
|
'sl',
|
||||||
'list',
|
'list',
|
||||||
'--json',
|
'--json',
|
||||||
|
|
@ -818,13 +741,14 @@ try {
|
||||||
'--project-dir',
|
'--project-dir',
|
||||||
projectDir,
|
projectDir,
|
||||||
]);
|
]);
|
||||||
const agentSlSearchJson = parseJsonResult('ktx agent sl list', agentSlSearch);
|
const slSearchJson = parseJsonResult('ktx sl list', slSearch);
|
||||||
assert.equal(agentSlSearchJson.totalSources, 1);
|
assert.equal(slSearchJson.kind, 'list');
|
||||||
assert.equal(agentSlSearchJson.sources[0].connectionId, 'warehouse');
|
assert.equal(slSearchJson.data.items.length, 1);
|
||||||
assert.equal(agentSlSearchJson.sources[0].name, 'orders');
|
assert.equal(slSearchJson.data.items[0].connectionId, 'warehouse');
|
||||||
assert.equal(typeof agentSlSearchJson.sources[0].score, 'number');
|
assert.equal(slSearchJson.data.items[0].name, 'orders');
|
||||||
requireIncludes(agentSlSearchJson.sources[0].matchReasons, 'lexical', 'agent sl search match reasons');
|
assert.equal(typeof slSearchJson.data.items[0].score, 'number');
|
||||||
process.stdout.write('ktx agent sl list hybrid metadata verified\\n');
|
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',
|
const slQuery = await run('pnpm', ['exec', 'ktx', 'sl', 'query',
|
||||||
'--connection-id',
|
'--connection-id',
|
||||||
|
|
|
||||||
|
|
@ -461,9 +461,9 @@ describe('verification snippets', () => {
|
||||||
assert.doesNotMatch(source, /startSemanticDaemon/);
|
assert.doesNotMatch(source, /startSemanticDaemon/);
|
||||||
assert.match(source, /run\('pnpm', \[\s*'exec',\s*'ktx',\s*'setup'/);
|
assert.match(source, /run\('pnpm', \[\s*'exec',\s*'ktx',\s*'setup'/);
|
||||||
assert.match(source, /knowledge', 'global', 'revenue\.md'/);
|
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, /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, /orders\.order_count/);
|
||||||
assert.match(source, /sqlite3/);
|
assert.match(source, /sqlite3/);
|
||||||
assert.match(source, /driver: sqlite/);
|
assert.match(source, /driver: sqlite/);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue