mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-21 06:29:59 +00:00
Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a35a69d1e2 | |||
| 0b3713d79a | |||
| b65c06a02f | |||
| 2027df3d30 | |||
| 54e443bd55 | |||
| 3de1a4e412 | |||
| 69ba6e8714 | |||
| 5e39345c8d | |||
| 185e598532 | |||
| e680dd9b7c | |||
| c2dae40303 | |||
| d43dd2d7e0 | |||
| 265b39615d | |||
| 2b46f65571 | |||
| 802a8aee64 | |||
| 4065dc0565 | |||
| 3529b46f2c | |||
| 8b29bb7fc9 | |||
| 804eb57dd8 | |||
| 2399f672e2 | |||
| 9c9e8e8ece | |||
| 2e45e24df3 | |||
| fded8dbb4e | |||
| 709c9749d0 | |||
| c07574af12 | |||
| b4624e6515 | |||
| f94f1ae08a | |||
| 165697ce47 | |||
| 14dd5d09dd | |||
| 21d1f0e472 | |||
| bc50db6a8b | |||
| 8db8dff7b0 | |||
| 1a3c561e21 | |||
| 8e60b9f620 | |||
| 874c2dd706 | |||
| 4988413d58 | |||
| f1dd2fc458 | |||
| aa8082d6b2 | |||
| 37cb4983de | |||
| 9098d0074a | |||
| 860e11ab3a | |||
| c2e9b45d4c | |||
| 8063378a1d | |||
| 93aed84399 |
@@ -0,0 +1,231 @@
|
||||
---
|
||||
name: cli
|
||||
description: LobeHub CLI (@lobehub/cli) development guide. Use when working on CLI commands, adding new subcommands, fixing CLI bugs, or understanding CLI architecture. Triggers on CLI development, command implementation, or `lh` command questions.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# LobeHub CLI Development Guide
|
||||
|
||||
## Overview
|
||||
|
||||
LobeHub CLI (`@lobehub/cli`) is a command-line tool for managing and interacting with LobeHub services. Built with Commander.js + TypeScript.
|
||||
|
||||
- **Package**: `apps/cli/`
|
||||
- **Entry**: `apps/cli/src/index.ts`
|
||||
- **Binaries**: `lh`, `lobe`, `lobehub` (all aliases for the same CLI)
|
||||
- **Build**: tsup
|
||||
- **Runtime**: Node.js / Bun
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
apps/cli/src/
|
||||
├── index.ts # Entry point, registers all commands
|
||||
├── api/
|
||||
│ ├── client.ts # tRPC client (type-safe backend API)
|
||||
│ └── http.ts # Raw HTTP utilities
|
||||
├── auth/
|
||||
│ ├── credentials.ts # Encrypted credential storage (AES-256-GCM)
|
||||
│ ├── refresh.ts # Token auto-refresh
|
||||
│ └── resolveToken.ts # Token resolution (flag > stored)
|
||||
├── commands/ # All CLI commands (one file per command group)
|
||||
│ ├── agent.ts # Agent CRUD + run
|
||||
│ ├── config.ts # whoami, usage
|
||||
│ ├── connect.ts # Device gateway connection + daemon
|
||||
│ ├── doc.ts # Document management
|
||||
│ ├── file.ts # File management
|
||||
│ ├── generate/ # Content generation (text/image/video/tts/asr)
|
||||
│ ├── kb.ts # Knowledge base management
|
||||
│ ├── login.ts # OIDC Device Code Flow auth
|
||||
│ ├── logout.ts # Clear credentials
|
||||
│ ├── memory.ts # User memory management
|
||||
│ ├── message.ts # Message management
|
||||
│ ├── model.ts # AI model management
|
||||
│ ├── plugin.ts # Plugin management
|
||||
│ ├── provider.ts # AI provider management
|
||||
│ ├── search.ts # Global search
|
||||
│ ├── skill.ts # Agent skill management
|
||||
│ ├── status.ts # Gateway connectivity check
|
||||
│ └── topic.ts # Conversation topic management
|
||||
├── daemon/
|
||||
│ └── manager.ts # Background daemon process management
|
||||
├── tools/
|
||||
│ ├── shell.ts # Shell command execution (for gateway)
|
||||
│ └── file.ts # File operations (for gateway)
|
||||
├── settings/
|
||||
│ └── index.ts # Persistent settings (~/.lobehub/)
|
||||
├── utils/
|
||||
│ ├── logger.ts # Logging (verbose mode)
|
||||
│ ├── format.ts # Table output, JSON, timeAgo, truncate
|
||||
│ └── agentStream.ts # SSE streaming for agent runs
|
||||
└── constants/
|
||||
└── urls.ts # Official server & gateway URLs
|
||||
```
|
||||
|
||||
## Command Groups
|
||||
|
||||
| Command | Alias | Description |
|
||||
| ------------- | ----- | ----------------------------------------------------------- |
|
||||
| `lh login` | - | Authenticate via OIDC Device Code Flow |
|
||||
| `lh logout` | - | Clear stored credentials |
|
||||
| `lh connect` | - | Device gateway connection & daemon management |
|
||||
| `lh status` | - | Quick gateway connectivity check |
|
||||
| `lh agent` | - | Agent CRUD, run, status |
|
||||
| `lh generate` | `gen` | Content generation (text, image, video, tts, asr, download) |
|
||||
| `lh doc` | - | Document CRUD, batch-create, parse, topic linking |
|
||||
| `lh file` | - | File list, view, delete, recent |
|
||||
| `lh kb` | - | Knowledge base CRUD, folders, docs, upload, tree view |
|
||||
| `lh memory` | - | User memory CRUD + extraction |
|
||||
| `lh message` | - | Message list, search, delete, count, heatmap |
|
||||
| `lh topic` | - | Topic CRUD + search + recent |
|
||||
| `lh skill` | - | Skill CRUD + import (GitHub/URL/market) |
|
||||
| `lh model` | - | Model CRUD, toggle, batch-toggle, clear |
|
||||
| `lh provider` | - | Provider CRUD, config, test, toggle |
|
||||
| `lh plugin` | - | Plugin install, uninstall, update |
|
||||
| `lh search` | - | Global search across all types |
|
||||
| `lh whoami` | - | Current user info |
|
||||
| `lh usage` | - | Monthly/daily usage statistics |
|
||||
|
||||
## Adding a New Command
|
||||
|
||||
### 1. Create Command File
|
||||
|
||||
Create `apps/cli/src/commands/<name>.ts`:
|
||||
|
||||
```typescript
|
||||
import type { Command } from 'commander';
|
||||
import { getTrpcClient } from '../api/client';
|
||||
import { outputJson, printTable, truncate } from '../utils/format';
|
||||
|
||||
export function register<Name>Command(program: Command) {
|
||||
const cmd = program.command('<name>').description('...');
|
||||
|
||||
// Subcommands
|
||||
cmd
|
||||
.command('list')
|
||||
.description('List items')
|
||||
.option('-L, --limit <n>', 'Maximum number of items', '30')
|
||||
.option('--json [fields]', 'Output JSON, optionally specify fields')
|
||||
.action(async (options) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.<router>.<procedure>.query({ ... });
|
||||
// Handle output
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Register in Entry Point
|
||||
|
||||
In `apps/cli/src/index.ts`:
|
||||
|
||||
```typescript
|
||||
import { registerNewCommand } from './commands/new';
|
||||
// ...
|
||||
registerNewCommand(program);
|
||||
```
|
||||
|
||||
### 3. Add Tests
|
||||
|
||||
Create `apps/cli/src/commands/<name>.test.ts` alongside the command file.
|
||||
|
||||
## Conventions
|
||||
|
||||
### Output Patterns
|
||||
|
||||
All list/view commands follow consistent patterns:
|
||||
|
||||
- `--json [fields]` - JSON output with optional field filtering
|
||||
- `--yes` - Skip confirmation for destructive ops
|
||||
- `-L, --limit <n>` - Pagination limit (default: 30)
|
||||
- `-v, --verbose` - Verbose logging
|
||||
|
||||
### Table Output
|
||||
|
||||
```typescript
|
||||
const rows = items.map((item) => [item.id, truncate(item.title, 40), timeAgo(item.updatedAt)]);
|
||||
printTable(rows, ['ID', 'TITLE', 'UPDATED']);
|
||||
```
|
||||
|
||||
### JSON Output
|
||||
|
||||
```typescript
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(items, fields);
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
||||
Commands that need auth use `getTrpcClient()` which auto-resolves tokens:
|
||||
|
||||
```typescript
|
||||
const client = await getTrpcClient();
|
||||
// client.router.procedure.query/mutate(...)
|
||||
```
|
||||
|
||||
### Confirmation Prompts
|
||||
|
||||
```typescript
|
||||
import { confirm } from '../utils/format';
|
||||
if (!options.yes) {
|
||||
const ok = await confirm('Are you sure?');
|
||||
if (!ok) return;
|
||||
}
|
||||
```
|
||||
|
||||
## Storage Locations
|
||||
|
||||
| File | Path | Purpose |
|
||||
| ------------- | ----------------------------- | ------------------------------ |
|
||||
| Credentials | `~/.lobehub/credentials.json` | Encrypted tokens (AES-256-GCM) |
|
||||
| Settings | `~/.lobehub/settings.json` | Custom server/gateway URLs |
|
||||
| Daemon PID | `~/.lobehub/daemon.pid` | Background process PID |
|
||||
| Daemon Status | `~/.lobehub/daemon.status` | Connection status JSON |
|
||||
| Daemon Log | `~/.lobehub/daemon.log` | Daemon output log |
|
||||
|
||||
The base directory (`~/.lobehub/`) can be overridden with the `LOBEHUB_CLI_HOME` env var (e.g. `LOBEHUB_CLI_HOME=.lobehub-dev` for dev mode isolation).
|
||||
|
||||
## Key Dependencies
|
||||
|
||||
- `commander` - CLI framework
|
||||
- `@trpc/client` + `superjson` - Type-safe API client
|
||||
- `@lobechat/device-gateway-client` - WebSocket gateway connection
|
||||
- `@lobechat/local-file-shell` - Local shell/file tool execution
|
||||
- `picocolors` - Terminal colors
|
||||
- `ws` - WebSocket
|
||||
- `diff` - Text diffing
|
||||
- `fast-glob` - File pattern matching
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Run directly (dev mode, uses ~/.lobehub-dev for credentials)
|
||||
cd apps/cli && bun run dev -- <command>
|
||||
|
||||
# Build
|
||||
cd apps/cli && bun run build
|
||||
|
||||
# Test (unit tests)
|
||||
cd apps/cli && bun run test
|
||||
|
||||
# E2E tests (requires authenticated CLI)
|
||||
cd apps/cli && bunx vitest run e2e/kb.e2e.test.ts
|
||||
|
||||
# Link globally for testing
|
||||
cd apps/cli && bun run cli:link
|
||||
```
|
||||
|
||||
## Detailed Command References
|
||||
|
||||
See `references/` for each command group:
|
||||
|
||||
- **Agent**: `references/agent.md` (CRUD, run, status)
|
||||
- **Content Generation**: `references/generate.md` (text, image, video, tts, asr, download)
|
||||
- **Knowledge & Files**: `references/knowledge.md` (kb, file, doc)
|
||||
- **Conversation**: `references/conversation.md` (topic, message)
|
||||
- **Memory**: `references/memory.md` (memory management, extraction)
|
||||
- **Skills & Plugins**: `references/skills-plugins.md` (skill, plugin)
|
||||
- **Models & Providers**: `references/models-providers.md` (model, provider)
|
||||
- **Search & Config**: `references/search-config.md` (search, whoami, usage)
|
||||
@@ -0,0 +1,144 @@
|
||||
# Agent Commands
|
||||
|
||||
Manage AI agents: create, edit, delete, list, run, and check status.
|
||||
|
||||
**Source**: `apps/cli/src/commands/agent.ts`
|
||||
|
||||
## `lh agent list`
|
||||
|
||||
List all agents.
|
||||
|
||||
```bash
|
||||
lh agent list [-L [-k [--json [fields]] < n > ] < keyword > ]
|
||||
```
|
||||
|
||||
| Option | Description | Default |
|
||||
| ------------------------- | -------------------------------------- | ------- |
|
||||
| `-L, --limit <n>` | Maximum items | `30` |
|
||||
| `-k, --keyword <keyword>` | Filter by keyword | - |
|
||||
| `--json [fields]` | JSON output with optional field filter | - |
|
||||
|
||||
**Table columns**: ID, TITLE, DESCRIPTION, MODEL
|
||||
|
||||
---
|
||||
|
||||
## `lh agent view <agentId>`
|
||||
|
||||
View agent configuration details.
|
||||
|
||||
```bash
|
||||
lh agent view [fields]] < agentId > [--json
|
||||
```
|
||||
|
||||
**Displays**: Title, description, model, provider, system role, plugins, tools.
|
||||
|
||||
---
|
||||
|
||||
## `lh agent create`
|
||||
|
||||
Create a new agent.
|
||||
|
||||
```bash
|
||||
lh agent create [options]
|
||||
```
|
||||
|
||||
| Option | Description | Required |
|
||||
| --------------------------- | -------------- | -------- |
|
||||
| `-t, --title <title>` | Agent title | No |
|
||||
| `-d, --description <desc>` | Description | No |
|
||||
| `-m, --model <model>` | Model ID | No |
|
||||
| `-p, --provider <provider>` | Provider ID | No |
|
||||
| `-s, --system-role <role>` | System prompt | No |
|
||||
| `--group <groupId>` | Agent group ID | No |
|
||||
|
||||
**Output**: Created agent ID and session ID.
|
||||
|
||||
---
|
||||
|
||||
## `lh agent edit <agentId>`
|
||||
|
||||
Update an existing agent. Same options as `create`, all optional. Only specified fields are updated.
|
||||
|
||||
```bash
|
||||
lh agent edit [-m [-s ... < agentId > [-t < title > ] < model > ] < role > ]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `lh agent delete <agentId>`
|
||||
|
||||
Delete an agent.
|
||||
|
||||
```bash
|
||||
lh agent delete < agentId > [--yes]
|
||||
```
|
||||
|
||||
Requires confirmation unless `--yes` is provided.
|
||||
|
||||
---
|
||||
|
||||
## `lh agent duplicate <agentId>`
|
||||
|
||||
Duplicate an existing agent.
|
||||
|
||||
```bash
|
||||
lh agent duplicate < agentId > [-t < title > ]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| --------------------- | ------------------------------------ |
|
||||
| `-t, --title <title>` | Optional new title for the duplicate |
|
||||
|
||||
**Output**: New agent ID.
|
||||
|
||||
---
|
||||
|
||||
## `lh agent run`
|
||||
|
||||
Start an agent execution (streaming SSE).
|
||||
|
||||
```bash
|
||||
lh agent run [options]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| --------------------- | -------------------------------------------- |
|
||||
| `-a, --agent-id <id>` | Agent ID to run |
|
||||
| `-s, --slug <slug>` | Agent slug (alternative to ID) |
|
||||
| `-p, --prompt <text>` | User prompt |
|
||||
| `-t, --topic-id <id>` | Reuse existing topic |
|
||||
| `--no-auto-start` | Don't auto-start the agent |
|
||||
| `--json` | Output full JSON event stream |
|
||||
| `-v, --verbose` | Show detailed tool call info |
|
||||
| `--replay <file>` | Replay events from saved JSON file (offline) |
|
||||
|
||||
### Streaming Behavior
|
||||
|
||||
Uses `utils/agentStream.ts` to handle Server-Sent Events:
|
||||
|
||||
1. Sends agent run request to backend
|
||||
2. Streams SSE events in real-time
|
||||
3. Displays: text chunks, tool call status, operation progress
|
||||
4. Shows final token usage and cost summary
|
||||
|
||||
### Replay Mode
|
||||
|
||||
`--replay <file>` reads a saved JSON event stream for offline debugging without server connection.
|
||||
|
||||
---
|
||||
|
||||
## `lh agent status <operationId>`
|
||||
|
||||
Check agent operation status.
|
||||
|
||||
```bash
|
||||
lh agent status [fields]] [--history] [--history-limit < operationId > [--json < n > ]
|
||||
```
|
||||
|
||||
| Option | Description | Default |
|
||||
| --------------------- | -------------------- | ------- |
|
||||
| `--json [fields]` | JSON output | - |
|
||||
| `--history` | Include step history | `false` |
|
||||
| `--history-limit <n>` | Max history entries | `10` |
|
||||
|
||||
**Displays**: Status (running/completed/failed), steps count, tokens used, cost, error info, timestamps.
|
||||
@@ -0,0 +1,122 @@
|
||||
# Conversation Commands (Topic & Message)
|
||||
|
||||
## Topic Management (`lh topic`)
|
||||
|
||||
Manage conversation topics (threads).
|
||||
|
||||
**Source**: `apps/cli/src/commands/topic.ts`
|
||||
|
||||
### `lh topic list`
|
||||
|
||||
```bash
|
||||
lh topic list [--agent-id [-L [--page [--json [fields]] < id > ] < n > ] < n > ]
|
||||
```
|
||||
|
||||
| Option | Description | Default |
|
||||
| ----------------- | --------------- | ------- |
|
||||
| `--agent-id <id>` | Filter by agent | - |
|
||||
| `-L, --limit <n>` | Page size | `30` |
|
||||
| `--page <n>` | Page number | `1` |
|
||||
|
||||
**Table columns**: ID, TITLE, FAV, UPDATED
|
||||
|
||||
### `lh topic search <keywords>`
|
||||
|
||||
```bash
|
||||
lh topic search [--json [fields]] < keywords > [--agent-id < id > ]
|
||||
```
|
||||
|
||||
### `lh topic create`
|
||||
|
||||
```bash
|
||||
lh topic create -t [--favorite] < title > [--agent-id < id > ]
|
||||
```
|
||||
|
||||
| Option | Description | Required |
|
||||
| --------------------- | -------------------- | -------- |
|
||||
| `-t, --title <title>` | Topic title | Yes |
|
||||
| `--agent-id <id>` | Associate with agent | No |
|
||||
| `--favorite` | Mark as favorite | No |
|
||||
|
||||
### `lh topic edit <id>`
|
||||
|
||||
```bash
|
||||
lh topic edit [--favorite] [--no-favorite] < id > [-t < title > ]
|
||||
```
|
||||
|
||||
### `lh topic delete <ids...>`
|
||||
|
||||
```bash
|
||||
lh topic delete [--yes] < id1 > [id2...]
|
||||
```
|
||||
|
||||
### `lh topic recent`
|
||||
|
||||
```bash
|
||||
lh topic recent [-L [--json [fields]] < n > ]
|
||||
```
|
||||
|
||||
| Option | Description | Default |
|
||||
| ----------------- | --------------- | ------- |
|
||||
| `-L, --limit <n>` | Number of items | `10` |
|
||||
|
||||
---
|
||||
|
||||
## Message Management (`lh message`)
|
||||
|
||||
Manage chat messages within topics.
|
||||
|
||||
**Source**: `apps/cli/src/commands/message.ts`
|
||||
|
||||
### `lh message list`
|
||||
|
||||
```bash
|
||||
lh message list [options] [--json [fields]]
|
||||
```
|
||||
|
||||
| Option | Description | Default |
|
||||
| ----------------- | ----------------------- | ------- |
|
||||
| `--topic-id <id>` | Filter by topic | - |
|
||||
| `--agent-id <id>` | Filter by agent | - |
|
||||
| `-L, --limit <n>` | Page size | `30` |
|
||||
| `--page <n>` | Page number | `1` |
|
||||
| `--user` | Only show user messages | - |
|
||||
|
||||
**Table columns**: ID, ROLE, CONTENT, CREATED
|
||||
|
||||
**Note**: When `--topic-id` or `--agent-id` is provided, uses `message.getMessages`; otherwise uses `message.listAll`.
|
||||
|
||||
### `lh message search <keywords>`
|
||||
|
||||
```bash
|
||||
lh message search [fields]] < keywords > [--json
|
||||
```
|
||||
|
||||
Full-text search across all messages.
|
||||
|
||||
### `lh message delete <ids...>`
|
||||
|
||||
```bash
|
||||
lh message delete [--yes] < id1 > [id2...]
|
||||
```
|
||||
|
||||
### `lh message count`
|
||||
|
||||
```bash
|
||||
lh message count [--start [--end [--json] < date > ] < date > ]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| ---------------- | ------------------------------------------ |
|
||||
| `--start <date>` | Start date (ISO format, e.g. `2024-01-01`) |
|
||||
| `--end <date>` | End date (ISO format) |
|
||||
|
||||
**Output**: Total message count for the specified period.
|
||||
|
||||
### `lh message heatmap`
|
||||
|
||||
```bash
|
||||
lh message heatmap [--json]
|
||||
```
|
||||
|
||||
**Output**: Activity heatmap data showing message frequency over time.
|
||||
@@ -0,0 +1,246 @@
|
||||
# Content Generation Commands
|
||||
|
||||
Generate text, images, videos, speech, and transcriptions.
|
||||
|
||||
**Source**: `apps/cli/src/commands/generate/`
|
||||
|
||||
## Command Structure
|
||||
|
||||
```
|
||||
lh generate (alias: gen)
|
||||
├── text <prompt> # Text generation
|
||||
├── image <prompt> # Image generation
|
||||
├── video <prompt> # Video generation
|
||||
├── tts <text> # Text-to-speech
|
||||
├── asr <audioFile> # Audio-to-text (speech recognition)
|
||||
├── download <genId> <taskId> # Wait & download generation result
|
||||
├── status <genId> <taskId> # Check async task status
|
||||
└── list # List generation topics
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `lh generate text <prompt>` / `lh gen text <prompt>`
|
||||
|
||||
Generate text completion.
|
||||
|
||||
**Source**: `apps/cli/src/commands/generate/text.ts`
|
||||
|
||||
```bash
|
||||
lh gen text "Explain quantum computing" [options]
|
||||
echo "context" | lh gen text "summarize" --pipe
|
||||
```
|
||||
|
||||
| Option | Description | Default |
|
||||
| --------------------------- | ---------------------------------- | -------------------- |
|
||||
| `-m, --model <model>` | Model ID | `openai/gpt-4o-mini` |
|
||||
| `-p, --provider <provider>` | Provider name | - |
|
||||
| `-s, --system <prompt>` | System prompt | - |
|
||||
| `--temperature <n>` | Temperature (0-2) | - |
|
||||
| `--max-tokens <n>` | Maximum output tokens | - |
|
||||
| `--stream` | Enable streaming output | `false` |
|
||||
| `--json` | Output full JSON response | `false` |
|
||||
| `--pipe` | Read additional context from stdin | `false` |
|
||||
|
||||
### Pipe Mode
|
||||
|
||||
When `--pipe` is used, reads stdin and prepends it to the prompt. Useful for piping file contents:
|
||||
|
||||
```bash
|
||||
cat README.md | lh gen text "summarize this" --pipe
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `lh generate image <prompt>` / `lh gen image <prompt>`
|
||||
|
||||
Generate images from text prompt. This is an async operation — the command submits the task and returns a generation ID + task ID for tracking.
|
||||
|
||||
**Source**: `apps/cli/src/commands/generate/image.ts`
|
||||
|
||||
```bash
|
||||
lh gen image "A sunset over mountains" [options]
|
||||
lh gen image "A cute cat" --model dall-e-3 --provider openai --json
|
||||
```
|
||||
|
||||
| Option | Description | Default |
|
||||
| --------------------------- | ---------------- | ---------- |
|
||||
| `-m, --model <model>` | Model ID | `dall-e-3` |
|
||||
| `-p, --provider <provider>` | Provider name | `openai` |
|
||||
| `-n, --num <n>` | Number of images | `1` |
|
||||
| `--width <px>` | Width in pixels | - |
|
||||
| `--height <px>` | Height in pixels | - |
|
||||
| `--steps <n>` | Number of steps | - |
|
||||
| `--seed <n>` | Random seed | - |
|
||||
| `--json` | Output raw JSON | `false` |
|
||||
|
||||
**Output** (non-JSON):
|
||||
|
||||
```
|
||||
✓ Image generation started
|
||||
Batch ID: gb_xxx
|
||||
1 image(s) queued
|
||||
Generation gen_xxx → Task <taskId>
|
||||
|
||||
Use "lh generate status <generationId> <taskId>" to check progress.
|
||||
```
|
||||
|
||||
**Typical workflow**:
|
||||
|
||||
```bash
|
||||
# Generate image, then wait & download
|
||||
lh gen image "A cute cat"
|
||||
lh gen download <generationId> <taskId> -o cat.png
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `lh generate video <prompt>` / `lh gen video <prompt>`
|
||||
|
||||
Generate video from text prompt. This is an async operation.
|
||||
|
||||
**Source**: `apps/cli/src/commands/generate/video.ts`
|
||||
|
||||
```bash
|
||||
lh gen video "A cat playing piano" -m < model > -p < provider > [options]
|
||||
```
|
||||
|
||||
| Option | Description | Required |
|
||||
| --------------------------- | ------------------------ | -------- |
|
||||
| `-m, --model <model>` | Model ID | Yes |
|
||||
| `-p, --provider <provider>` | Provider name | Yes |
|
||||
| `--aspect-ratio <ratio>` | Aspect ratio (e.g. 16:9) | No |
|
||||
| `--duration <sec>` | Duration in seconds | No |
|
||||
| `--resolution <res>` | Resolution (e.g. 720p) | No |
|
||||
| `--seed <n>` | Random seed | No |
|
||||
| `--json` | Output raw JSON | No |
|
||||
|
||||
**Note**: Unlike image, video requires `-m` and `-p` (no defaults). Use `lh model list <provider> --type video` to find available video models.
|
||||
|
||||
**Output** (non-JSON):
|
||||
|
||||
```
|
||||
✓ Video generation started
|
||||
Batch ID: gb_xxx
|
||||
Generation gen_xxx → Task <taskId>
|
||||
|
||||
Use "lh generate status <generationId> <taskId>" to check progress.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `lh generate tts <text>` / `lh gen tts <text>`
|
||||
|
||||
Text-to-speech generation.
|
||||
|
||||
**Source**: `apps/cli/src/commands/generate/tts.ts`
|
||||
|
||||
```bash
|
||||
lh gen tts "Hello, world!" [options]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `lh generate asr <audioFile>` / `lh gen asr <audioFile>`
|
||||
|
||||
Audio-to-text transcription (Automatic Speech Recognition).
|
||||
|
||||
**Source**: `apps/cli/src/commands/generate/asr.ts`
|
||||
|
||||
```bash
|
||||
lh gen asr recording.wav [options]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `lh generate download <generationId> <taskId>`
|
||||
|
||||
Wait for an async generation task to complete and download the result file.
|
||||
|
||||
**Source**: `apps/cli/src/commands/generate/index.ts`
|
||||
|
||||
```bash
|
||||
lh gen download <generationId> <taskId> [-o output.png]
|
||||
lh gen download gen_xxx task_xxx -o ~/Desktop/result.mp4 --timeout 600
|
||||
```
|
||||
|
||||
| Option | Description | Default |
|
||||
| --------------------- | ---------------------------------------- | ---------------------- |
|
||||
| `-o, --output <path>` | Output file path (auto-detect extension) | `<generationId>.<ext>` |
|
||||
| `--interval <sec>` | Polling interval in seconds | `5` |
|
||||
| `--timeout <sec>` | Timeout in seconds (0 = no timeout) | `300` |
|
||||
|
||||
**Behavior**:
|
||||
|
||||
1. Polls `generation.getGenerationStatus` at the specified interval
|
||||
2. Shows live progress: `⋯ Status: processing... (42s)`
|
||||
3. On success: downloads asset URL to local file
|
||||
4. On error: displays error message and exits
|
||||
5. On timeout: suggests using `lh gen status` to check later
|
||||
|
||||
**Typical workflow**:
|
||||
|
||||
```bash
|
||||
# One-shot: generate and download
|
||||
lh gen image "A sunset"
|
||||
# Copy the generation ID and task ID from output
|
||||
lh gen download gen_xxx taskId_xxx -o sunset.png
|
||||
|
||||
# Video (longer timeout)
|
||||
lh gen video "A cat running" -m model -p provider
|
||||
lh gen download gen_xxx taskId_xxx -o cat.mp4 --timeout 600
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `lh generate status <generationId> <taskId>`
|
||||
|
||||
Check the status of an async generation task.
|
||||
|
||||
```bash
|
||||
lh gen status <generationId> <taskId> [--json]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| -------- | ------------------------ |
|
||||
| `--json` | Output raw JSON response |
|
||||
|
||||
**Displays**:
|
||||
|
||||
- Status (color-coded): `success` (green), `error` (red), `processing` (yellow), `pending` (cyan)
|
||||
- Error message (if failed)
|
||||
- Asset URL and thumbnail URL (if completed)
|
||||
|
||||
---
|
||||
|
||||
## `lh generate list`
|
||||
|
||||
List all generation topics.
|
||||
|
||||
```bash
|
||||
lh gen list [--json [fields]]
|
||||
```
|
||||
|
||||
**Table columns**: ID, TITLE, TYPE, UPDATED
|
||||
|
||||
---
|
||||
|
||||
## Backend Architecture
|
||||
|
||||
Image and video generation use an async task pattern:
|
||||
|
||||
1. **Create topic** → `generationTopic.createTopic`
|
||||
2. **Submit generation** → `image.createImage` / `video.createVideo`
|
||||
- Creates batch + generation + asyncTask records in a DB transaction
|
||||
- Triggers async background task (image via `createAsyncCaller`, video via `initModelRuntimeFromDB`)
|
||||
- Returns `{ data: { batch, generations }, success }` with `asyncTaskId` in each generation
|
||||
3. **Poll status** → `generation.getGenerationStatus`
|
||||
- Returns `{ status, error, generation }` (generation includes asset URLs on success)
|
||||
|
||||
**Server routes**:
|
||||
|
||||
- `src/server/routers/lambda/image/index.ts` — image creation (uses `authedProcedure` + `serverDatabase`)
|
||||
- `src/server/routers/lambda/video/index.ts` — video creation (uses `authedProcedure` + `serverDatabase`)
|
||||
- `src/server/routers/lambda/generation.ts` — status checking
|
||||
|
||||
**Note**: Image/video routes do NOT use the `keyVaults` middleware — they read API keys from the database via `initModelRuntimeFromDB` or `createAsyncCaller`.
|
||||
@@ -0,0 +1,281 @@
|
||||
# Knowledge Base, File & Document Commands
|
||||
|
||||
## Knowledge Base (`lh kb`)
|
||||
|
||||
Manage knowledge bases for RAG (Retrieval-Augmented Generation). Supports directory tree structure with folders, documents, and file uploads.
|
||||
|
||||
**Source**: `apps/cli/src/commands/kb.ts`
|
||||
|
||||
### `lh kb list`
|
||||
|
||||
```bash
|
||||
lh kb list [--json [fields]]
|
||||
```
|
||||
|
||||
**Table columns**: ID, NAME, DESCRIPTION, UPDATED
|
||||
|
||||
### `lh kb view <id>`
|
||||
|
||||
```bash
|
||||
lh kb view [fields]] < id > [--json
|
||||
```
|
||||
|
||||
**Displays**: Name, description, full directory tree with all files and documents (recursively fetched). Shows indented tree structure with item type (File/Doc), file type, and size.
|
||||
|
||||
**API**: Uses `file.getKnowledgeItems` to recursively fetch items. Folders (`custom/folder` fileType) are traversed in parallel via `Promise.all` for performance.
|
||||
|
||||
### `lh kb create`
|
||||
|
||||
```bash
|
||||
lh kb create -n [--avatar < name > [-d < desc > ] < url > ]
|
||||
```
|
||||
|
||||
| Option | Description | Required |
|
||||
| -------------------------- | ------------------- | -------- |
|
||||
| `-n, --name <name>` | Knowledge base name | Yes |
|
||||
| `-d, --description <desc>` | Description | No |
|
||||
| `--avatar <url>` | Avatar URL | No |
|
||||
|
||||
**Output**: Created KB ID. Note: backend returns ID as a string directly (not an object).
|
||||
|
||||
### `lh kb edit <id>`
|
||||
|
||||
```bash
|
||||
lh kb edit [-d [--avatar < id > [-n < name > ] < desc > ] < url > ]
|
||||
```
|
||||
|
||||
Requires at least one change flag. Errors if none specified.
|
||||
|
||||
### `lh kb delete <id>`
|
||||
|
||||
```bash
|
||||
lh kb delete [--yes] < id > [--remove-files]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| ---------------- | ---------------------------- |
|
||||
| `--remove-files` | Also delete associated files |
|
||||
| `--yes` | Skip confirmation |
|
||||
|
||||
### `lh kb add-files <knowledgeBaseId>`
|
||||
|
||||
```bash
|
||||
lh kb add-files <kbId> --ids <fileId1> <fileId2> ...
|
||||
```
|
||||
|
||||
Link existing files to a knowledge base.
|
||||
|
||||
### `lh kb remove-files <knowledgeBaseId>`
|
||||
|
||||
```bash
|
||||
lh kb remove-files <kbId> --ids <fileId1> <fileId2> ... [--yes]
|
||||
```
|
||||
|
||||
Unlink files from a knowledge base.
|
||||
|
||||
### `lh kb mkdir <knowledgeBaseId>`
|
||||
|
||||
```bash
|
||||
lh kb mkdir < kbId > -n < name > [--parent < folderId > ]
|
||||
```
|
||||
|
||||
Create a folder in a knowledge base. Uses `document.createDocument` with `fileType: 'custom/folder'`.
|
||||
|
||||
| Option | Description | Required |
|
||||
| --------------------- | ---------------- | -------- |
|
||||
| `-n, --name <name>` | Folder name | Yes |
|
||||
| `--parent <parentId>` | Parent folder ID | No |
|
||||
|
||||
### `lh kb create-doc <knowledgeBaseId>`
|
||||
|
||||
```bash
|
||||
lh kb create-doc [--parent < kbId > -t < title > [-c < content > ] < folderId > ]
|
||||
```
|
||||
|
||||
Create a document in a knowledge base. Uses `document.createDocument` with `fileType: 'custom/document'`.
|
||||
|
||||
| Option | Description | Required |
|
||||
| ---------------------- | ---------------- | -------- |
|
||||
| `-t, --title <title>` | Document title | Yes |
|
||||
| `-c, --content <text>` | Document content | No |
|
||||
| `--parent <parentId>` | Parent folder ID | No |
|
||||
|
||||
### `lh kb move <id>`
|
||||
|
||||
```bash
|
||||
lh kb move < id > --type < file | doc > [--parent < folderId > ]
|
||||
```
|
||||
|
||||
Move a file or document to a different folder (or to root if `--parent` is omitted).
|
||||
|
||||
| Option | Description | Default |
|
||||
| --------------------- | -------------------------------- | ------- |
|
||||
| `--type <type>` | Item type: `file` or `doc` | `file` |
|
||||
| `--parent <parentId>` | Target folder ID (omit for root) | - |
|
||||
|
||||
Uses `document.updateDocument` for docs, `file.updateFile` for files.
|
||||
|
||||
### `lh kb upload <knowledgeBaseId> <filePath>`
|
||||
|
||||
```bash
|
||||
lh kb upload <kbId> <filePath> [--parent <folderId>]
|
||||
```
|
||||
|
||||
Upload a local file to a knowledge base via S3 presigned URL.
|
||||
|
||||
| Option | Description |
|
||||
| --------------------- | ---------------- |
|
||||
| `--parent <parentId>` | Parent folder ID |
|
||||
|
||||
**Flow**: Compute SHA-256 hash → get presigned URL via `upload.createS3PreSignedUrl` → PUT to S3 → create file record via `file.createFile`.
|
||||
|
||||
---
|
||||
|
||||
## File Management (`lh file`)
|
||||
|
||||
Manage uploaded files.
|
||||
|
||||
**Source**: `apps/cli/src/commands/file.ts`
|
||||
|
||||
### `lh file list`
|
||||
|
||||
```bash
|
||||
lh file list [--kb-id [-L [--json [fields]] < id > ] < n > ]
|
||||
```
|
||||
|
||||
| Option | Description | Default |
|
||||
| ----------------- | ------------------------ | ------- |
|
||||
| `--kb-id <id>` | Filter by knowledge base | - |
|
||||
| `-L, --limit <n>` | Maximum items | `30` |
|
||||
|
||||
**Table columns**: ID, NAME, TYPE, SIZE, UPDATED
|
||||
|
||||
### `lh file view <id>`
|
||||
|
||||
```bash
|
||||
lh file view [fields]] < id > [--json
|
||||
```
|
||||
|
||||
**Displays**: Name, type, size, chunking status, embedding status.
|
||||
|
||||
### `lh file delete <ids...>`
|
||||
|
||||
```bash
|
||||
lh file delete [--yes] < id1 > [id2...]
|
||||
```
|
||||
|
||||
Supports deleting multiple files at once.
|
||||
|
||||
### `lh file recent`
|
||||
|
||||
```bash
|
||||
lh file recent [-L [--json [fields]] < n > ]
|
||||
```
|
||||
|
||||
| Option | Description | Default |
|
||||
| ----------------- | --------------- | ------- |
|
||||
| `-L, --limit <n>` | Number of items | `10` |
|
||||
|
||||
---
|
||||
|
||||
## Document Management (`lh doc`)
|
||||
|
||||
Manage text documents (notes, wiki pages).
|
||||
|
||||
**Source**: `apps/cli/src/commands/doc.ts`
|
||||
|
||||
### `lh doc list`
|
||||
|
||||
```bash
|
||||
lh doc list [-L [--file-type [--source-type [--json [fields]] < n > ] < type > ] < type > ]
|
||||
```
|
||||
|
||||
| Option | Description | Default |
|
||||
| ---------------------- | --------------------------------------------- | ------- |
|
||||
| `-L, --limit <n>` | Maximum items | `30` |
|
||||
| `--file-type <type>` | Filter by file type | - |
|
||||
| `--source-type <type>` | Filter by source type (file, web, api, topic) | - |
|
||||
|
||||
**Table columns**: ID, TITLE, TYPE, UPDATED
|
||||
|
||||
### `lh doc view <id>`
|
||||
|
||||
```bash
|
||||
lh doc view [fields]] < id > [--json
|
||||
```
|
||||
|
||||
**Displays**: Title, type, KB association, updated time, full content.
|
||||
|
||||
### `lh doc create`
|
||||
|
||||
```bash
|
||||
lh doc create -t [-F [--parent [--slug [--kb [--file-type < title > [-b < body > ] < path > ] < id > ] < slug > ] < id > ] < type > ]
|
||||
```
|
||||
|
||||
| Option | Description | Required |
|
||||
| ------------------------ | ----------------------------------------------- | -------- |
|
||||
| `-t, --title <title>` | Document title | Yes |
|
||||
| `-b, --body <content>` | Document body text | No |
|
||||
| `-F, --body-file <path>` | Read body from file | No |
|
||||
| `--parent <id>` | Parent document ID | No |
|
||||
| `--slug <slug>` | Custom URL slug | No |
|
||||
| `--kb <id>` | Knowledge base ID to associate with | No |
|
||||
| `--file-type <type>` | File type (e.g. custom/document, custom/folder) | No |
|
||||
|
||||
`-b` and `-F` are mutually exclusive; `-F` reads the file content as the body.
|
||||
|
||||
### `lh doc batch-create <file>`
|
||||
|
||||
Batch create documents from a JSON file. The file must contain a non-empty array of document objects.
|
||||
|
||||
```bash
|
||||
lh doc batch-create documents.json
|
||||
```
|
||||
|
||||
Each object in the array can have: `title`, `content`, `fileType`, `knowledgeBaseId`, `parentId`, `slug`.
|
||||
|
||||
### `lh doc edit <id>`
|
||||
|
||||
```bash
|
||||
lh doc edit [-b [-F [--parent [--file-type < id > [-t < title > ] < body > ] < path > ] < id > ] < type > ]
|
||||
```
|
||||
|
||||
### `lh doc delete <ids...>`
|
||||
|
||||
```bash
|
||||
lh doc delete [--yes] < id1 > [id2...]
|
||||
```
|
||||
|
||||
### `lh doc parse <fileId>`
|
||||
|
||||
Parse an uploaded file into a document.
|
||||
|
||||
```bash
|
||||
lh doc parse [--json [fields]] < fileId > [--with-pages]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| -------------- | ----------------------- |
|
||||
| `--with-pages` | Preserve page structure |
|
||||
|
||||
**Output**: Parsed title and content preview.
|
||||
|
||||
### `lh doc link-topic <docId> <topicId>`
|
||||
|
||||
Associate a document with a topic. Creates a linked copy via the notebook router.
|
||||
|
||||
```bash
|
||||
lh doc link-topic <docId> <topicId>
|
||||
```
|
||||
|
||||
### `lh doc topic-docs <topicId>`
|
||||
|
||||
List documents associated with a topic.
|
||||
|
||||
```bash
|
||||
lh doc topic-docs [--json [fields]] < topicId > [--type < type > ]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| --------------- | ------------------------------------------------ |
|
||||
| `--type <type>` | Filter by type (article, markdown, note, report) |
|
||||
@@ -0,0 +1,138 @@
|
||||
# Memory Commands
|
||||
|
||||
Manage user memories - the AI's long-term knowledge about users.
|
||||
|
||||
**Source**: `apps/cli/src/commands/memory.ts`
|
||||
|
||||
## Memory Categories
|
||||
|
||||
| Category | Description |
|
||||
| ------------ | ----------------------------------------- |
|
||||
| `identity` | User's name, role, relationships |
|
||||
| `activity` | Recent activities and their status |
|
||||
| `context` | Ongoing contexts, projects, goals |
|
||||
| `experience` | Past experiences and key learnings |
|
||||
| `preference` | User preferences, directives, suggestions |
|
||||
|
||||
---
|
||||
|
||||
## `lh memory list [category]`
|
||||
|
||||
List memory entries, optionally filtered by category.
|
||||
|
||||
```bash
|
||||
lh memory list # All categories
|
||||
lh memory list identity # Only identity memories
|
||||
lh memory list preference # Only preferences
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| ----------------- | ----------- |
|
||||
| `--json [fields]` | JSON output |
|
||||
|
||||
**Output**: Grouped by category, showing type/status and descriptions.
|
||||
|
||||
---
|
||||
|
||||
## `lh memory create`
|
||||
|
||||
Create a new identity memory entry.
|
||||
|
||||
```bash
|
||||
lh memory create [options]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| -------------------------- | ------------------------ |
|
||||
| `--type <type>` | Memory type |
|
||||
| `--role <role>` | User's role |
|
||||
| `--relationship <rel>` | Relationship description |
|
||||
| `-d, --description <desc>` | Description |
|
||||
| `--labels <labels...>` | Extracted labels |
|
||||
|
||||
---
|
||||
|
||||
## `lh memory edit <category> <id>`
|
||||
|
||||
Edit a memory entry. Options vary by category:
|
||||
|
||||
```bash
|
||||
lh memory edit identity < id > [options]
|
||||
lh memory edit activity < id > [options]
|
||||
lh memory edit context < id > [options]
|
||||
lh memory edit experience < id > [options]
|
||||
lh memory edit preference < id > [options]
|
||||
```
|
||||
|
||||
### Category-specific Options
|
||||
|
||||
**identity**:
|
||||
|
||||
- `--type <type>`, `--role <role>`, `--relationship <rel>`
|
||||
|
||||
**activity**:
|
||||
|
||||
- `--narrative <text>`, `--notes <text>`, `--status <status>`
|
||||
|
||||
**context**:
|
||||
|
||||
- `--title <title>`, `--description <desc>`, `--status <status>`
|
||||
|
||||
**experience**:
|
||||
|
||||
- `--situation <text>`, `--action <text>`, `--key-learning <text>`
|
||||
|
||||
**preference**:
|
||||
|
||||
- `--directives <text>`, `--suggestions <text>`
|
||||
|
||||
---
|
||||
|
||||
## `lh memory delete <category> <id>`
|
||||
|
||||
```bash
|
||||
lh memory delete identity < id > [--yes]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `lh memory persona`
|
||||
|
||||
Display the compiled memory persona summary.
|
||||
|
||||
```bash
|
||||
lh memory persona [--json [fields]]
|
||||
```
|
||||
|
||||
**Output**: Summarized user profile built from all memory categories.
|
||||
|
||||
---
|
||||
|
||||
## `lh memory extract`
|
||||
|
||||
Trigger async memory extraction from chat history.
|
||||
|
||||
```bash
|
||||
lh memory extract [--from [--to < date > ] < date > ]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| --------------- | ----------------------- |
|
||||
| `--from <date>` | Start date (ISO format) |
|
||||
| `--to <date>` | End date (ISO format) |
|
||||
|
||||
Starts a background task that analyzes chat history and creates new memory entries.
|
||||
|
||||
---
|
||||
|
||||
## `lh memory extract-status`
|
||||
|
||||
Check the status of a memory extraction task.
|
||||
|
||||
```bash
|
||||
lh memory extract-status [--task-id [--json [fields]] < id > ]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| ---------------- | ------------------- |
|
||||
| `--task-id <id>` | Check specific task |
|
||||
@@ -0,0 +1,186 @@
|
||||
# Model & Provider Commands
|
||||
|
||||
## Model Management (`lh model`)
|
||||
|
||||
Manage AI models within providers.
|
||||
|
||||
**Source**: `apps/cli/src/commands/model.ts`
|
||||
|
||||
### `lh model list <providerId>`
|
||||
|
||||
List models for a specific provider.
|
||||
|
||||
```bash
|
||||
lh model list openai
|
||||
lh model list openai --type image --enabled
|
||||
lh model list lobehub --type video --json
|
||||
```
|
||||
|
||||
| Option | Description | Default |
|
||||
| ----------------- | -------------------------------------------------------------------------------------- | ------- |
|
||||
| `-L, --limit <n>` | Maximum items | `50` |
|
||||
| `--enabled` | Only show enabled models | `false` |
|
||||
| `--type <type>` | Filter by model type (`chat\|embedding\|tts\|stt\|image\|video\|text2music\|realtime`) | - |
|
||||
| `--json [fields]` | Output JSON, optionally specify fields | - |
|
||||
|
||||
**Table columns**: ID, NAME, ENABLED, TYPE
|
||||
|
||||
**Backend**: `aiModel.getAiProviderModelList` → `AiInfraRepos.getAiProviderModelList` (supports `type` filter at repository level)
|
||||
|
||||
### `lh model view <id>`
|
||||
|
||||
```bash
|
||||
lh model view [fields]] < modelId > [--json
|
||||
```
|
||||
|
||||
**Displays**: Name, provider, type, enabled status, capabilities.
|
||||
|
||||
### `lh model create`
|
||||
|
||||
```bash
|
||||
lh model create --id [--type < id > --provider < providerId > [--display-name < name > ] < type > ]
|
||||
```
|
||||
|
||||
| Option | Description | Default |
|
||||
| ------------------------- | ------------ | -------- |
|
||||
| `--id <id>` | Model ID | Required |
|
||||
| `--provider <providerId>` | Provider ID | Required |
|
||||
| `--display-name <name>` | Display name | - |
|
||||
| `--type <type>` | Model type | `chat` |
|
||||
|
||||
### `lh model edit <id>`
|
||||
|
||||
```bash
|
||||
lh model edit [--type < modelId > --provider < providerId > [--display-name < name > ] < type > ]
|
||||
```
|
||||
|
||||
### `lh model toggle <id>`
|
||||
|
||||
Enable or disable a model.
|
||||
|
||||
```bash
|
||||
lh model toggle < modelId > --provider < providerId > --enable
|
||||
lh model toggle < modelId > --provider < providerId > --disable
|
||||
```
|
||||
|
||||
| Option | Description | Required |
|
||||
| ------------------------- | ----------------- | ------------ |
|
||||
| `--provider <providerId>` | Provider ID | Yes |
|
||||
| `--enable` | Enable the model | One required |
|
||||
| `--disable` | Disable the model | One required |
|
||||
|
||||
### `lh model batch-toggle <ids...>`
|
||||
|
||||
Enable or disable multiple models at once.
|
||||
|
||||
```bash
|
||||
lh model batch-toggle model1 model2 model3 --provider openai --enable
|
||||
```
|
||||
|
||||
### `lh model delete <id>`
|
||||
|
||||
```bash
|
||||
lh model delete < modelId > --provider < providerId > [--yes]
|
||||
```
|
||||
|
||||
### `lh model clear`
|
||||
|
||||
Clear all models (or only remote/fetched models) for a provider.
|
||||
|
||||
```bash
|
||||
lh model clear --provider [--yes] < providerId > [--remote]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Provider Management (`lh provider`)
|
||||
|
||||
Manage AI service providers.
|
||||
|
||||
**Source**: `apps/cli/src/commands/provider.ts`
|
||||
|
||||
### `lh provider list`
|
||||
|
||||
```bash
|
||||
lh provider list [--json [fields]]
|
||||
```
|
||||
|
||||
**Table columns**: ID, NAME, ENABLED, SOURCE
|
||||
|
||||
### `lh provider view <id>`
|
||||
|
||||
```bash
|
||||
lh provider view [fields]] < providerId > [--json
|
||||
```
|
||||
|
||||
**Displays**: Name, enabled status, source, configuration.
|
||||
|
||||
### `lh provider create`
|
||||
|
||||
```bash
|
||||
lh provider create --id [-d [--logo [--sdk-type < id > -n < name > [-s < source > ] < desc > ] < url > ] < type > ]
|
||||
```
|
||||
|
||||
| Option | Description | Default |
|
||||
| -------------------------- | ------------------------------------------------- | -------- |
|
||||
| `--id <id>` | Provider ID | Required |
|
||||
| `-n, --name <name>` | Provider name | Required |
|
||||
| `-s, --source <source>` | Source type (`builtin` or `custom`) | `custom` |
|
||||
| `-d, --description <desc>` | Provider description | - |
|
||||
| `--logo <logo>` | Provider logo URL | - |
|
||||
| `--sdk-type <sdkType>` | SDK type (openai, anthropic, azure, bedrock, ...) | - |
|
||||
|
||||
### `lh provider edit <id>`
|
||||
|
||||
```bash
|
||||
lh provider edit [-d [--logo [--sdk-type < providerId > [-n < name > ] < desc > ] < url > ] < type > ]
|
||||
```
|
||||
|
||||
Requires at least one change flag.
|
||||
|
||||
### `lh provider config <id>`
|
||||
|
||||
Configure provider settings (API key, base URL, etc.).
|
||||
|
||||
```bash
|
||||
lh provider config openai --api-key sk-xxx
|
||||
lh provider config openai --base-url https://custom-endpoint.com
|
||||
lh provider config openai --show
|
||||
lh provider config openai --show --json
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| ------------------------ | --------------------------------- |
|
||||
| `--api-key <key>` | Set API key |
|
||||
| `--base-url <url>` | Set base URL |
|
||||
| `--check-model <model>` | Set connectivity check model |
|
||||
| `--enable-response-api` | Enable Response API mode (OpenAI) |
|
||||
| `--disable-response-api` | Disable Response API mode |
|
||||
| `--fetch-on-client` | Enable fetching models on client |
|
||||
| `--no-fetch-on-client` | Disable fetching models on client |
|
||||
| `--show` | Show current config |
|
||||
| `--json [fields]` | Output JSON (with --show) |
|
||||
|
||||
**Important**: The `lobehub` provider is platform-managed. Attempting to set `--api-key` or `--base-url` on it will be rejected with an error message.
|
||||
|
||||
### `lh provider test <id>`
|
||||
|
||||
Test provider connectivity.
|
||||
|
||||
```bash
|
||||
lh provider test openai
|
||||
lh provider test openai -m gpt-4o --json
|
||||
```
|
||||
|
||||
### `lh provider toggle <id>`
|
||||
|
||||
```bash
|
||||
lh provider toggle < providerId > --enable
|
||||
lh provider toggle < providerId > --disable
|
||||
```
|
||||
|
||||
### `lh provider delete <id>`
|
||||
|
||||
```bash
|
||||
lh provider delete < providerId > [--yes]
|
||||
```
|
||||
@@ -0,0 +1,94 @@
|
||||
# Search & Configuration Commands
|
||||
|
||||
## Global Search (`lh search`)
|
||||
|
||||
Search across all LobeHub resource types.
|
||||
|
||||
**Source**: `apps/cli/src/commands/search.ts`
|
||||
|
||||
### `lh search <query>`
|
||||
|
||||
```bash
|
||||
lh search "meeting notes" [-t [-L [--json [fields]] < type > ] < n > ]
|
||||
```
|
||||
|
||||
| Option | Description | Default |
|
||||
| ------------------- | ----------------------- | --------- |
|
||||
| `-t, --type <type>` | Filter by resource type | All types |
|
||||
| `-L, --limit <n>` | Results per type | `10` |
|
||||
|
||||
### Searchable Types
|
||||
|
||||
| Type | Description |
|
||||
| ---------------- | ---------------------------- |
|
||||
| `agent` | AI agents |
|
||||
| `topic` | Conversation topics |
|
||||
| `file` | Uploaded files |
|
||||
| `folder` | File folders |
|
||||
| `message` | Chat messages |
|
||||
| `page` | Documents/pages |
|
||||
| `memory` | User memories |
|
||||
| `mcp` | MCP servers |
|
||||
| `plugin` | Installed plugins |
|
||||
| `communityAgent` | Community marketplace agents |
|
||||
| `knowledgeBase` | Knowledge bases |
|
||||
|
||||
**Output**: Results grouped by type, showing ID, title/name, description.
|
||||
|
||||
---
|
||||
|
||||
## User Configuration (`lh whoami` / `lh usage`)
|
||||
|
||||
**Source**: `apps/cli/src/commands/config.ts`
|
||||
|
||||
### `lh whoami`
|
||||
|
||||
Display current authenticated user information.
|
||||
|
||||
```bash
|
||||
lh whoami [--json [fields]]
|
||||
```
|
||||
|
||||
**Displays**: Name, username, email, user ID, subscription plan.
|
||||
|
||||
### `lh usage`
|
||||
|
||||
Display usage statistics.
|
||||
|
||||
```bash
|
||||
lh usage [--month [--daily] [--json [fields]] < YYYY-MM > ]
|
||||
```
|
||||
|
||||
| Option | Description | Default |
|
||||
| ------------------- | -------------- | ----------------------- |
|
||||
| `--month <YYYY-MM>` | Month to query | Current month |
|
||||
| `--daily` | Group by day | `false` (monthly total) |
|
||||
|
||||
**Output**: Token usage, costs, and model breakdown for the specified period.
|
||||
|
||||
---
|
||||
|
||||
## Global Options
|
||||
|
||||
These options are available across most commands:
|
||||
|
||||
| Option | Description |
|
||||
| ----------------- | ---------------------------------------------------------------------- |
|
||||
| `--json [fields]` | Output as JSON; optionally filter to specific fields (comma-separated) |
|
||||
| `--yes` | Skip confirmation prompts for destructive operations |
|
||||
| `-L, --limit <n>` | Pagination limit for list commands |
|
||||
| `-v, --verbose` | Enable verbose/debug logging |
|
||||
| `--help` | Show command help |
|
||||
| `--version` | Show CLI version |
|
||||
|
||||
### JSON Field Filtering
|
||||
|
||||
The `--json` option supports field selection:
|
||||
|
||||
```bash
|
||||
# Full JSON output
|
||||
lh agent list --json
|
||||
|
||||
# Only specific fields
|
||||
lh agent list --json "id,title,model"
|
||||
```
|
||||
@@ -0,0 +1,149 @@
|
||||
# Skill & Plugin Commands
|
||||
|
||||
## Skill Management (`lh skill`)
|
||||
|
||||
Manage agent skills (custom instructions and capabilities).
|
||||
|
||||
**Source**: `apps/cli/src/commands/skill.ts`
|
||||
|
||||
### `lh skill list`
|
||||
|
||||
```bash
|
||||
lh skill list [--source [--json [fields]] < source > ]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| ------------------- | ----------------------------------- |
|
||||
| `--source <source>` | Filter: `builtin`, `market`, `user` |
|
||||
|
||||
**Table columns**: ID, NAME, DESCRIPTION, SOURCE, IDENTIFIER
|
||||
|
||||
### `lh skill view <id>`
|
||||
|
||||
```bash
|
||||
lh skill view [fields]] < id > [--json
|
||||
```
|
||||
|
||||
**Displays**: Name, description, source, identifier, content.
|
||||
|
||||
### `lh skill create`
|
||||
|
||||
```bash
|
||||
lh skill create -n < name > -d < desc > -c < content > [-i < identifier > ]
|
||||
```
|
||||
|
||||
| Option | Description | Required |
|
||||
| -------------------------- | ----------------------------------- | -------- |
|
||||
| `-n, --name <name>` | Skill name | Yes |
|
||||
| `-d, --description <desc>` | Description | Yes |
|
||||
| `-c, --content <content>` | Skill content (prompt/instructions) | Yes |
|
||||
| `-i, --identifier <id>` | Custom identifier | No |
|
||||
|
||||
### `lh skill edit <id>`
|
||||
|
||||
```bash
|
||||
lh skill edit [-n [-d < id > [-c < content > ] < name > ] < desc > ]
|
||||
```
|
||||
|
||||
### `lh skill delete <id>`
|
||||
|
||||
```bash
|
||||
lh skill delete < id > [--yes]
|
||||
```
|
||||
|
||||
### `lh skill search <query>`
|
||||
|
||||
```bash
|
||||
lh skill search [fields]] < query > [--json
|
||||
```
|
||||
|
||||
### `lh skill install <source>` (alias: `lh skill i`)
|
||||
|
||||
Install a skill. Auto-detects source type from the input:
|
||||
|
||||
```bash
|
||||
# GitHub (URL or owner/repo shorthand)
|
||||
lh skill install lobehub/skill-repo
|
||||
lh skill install https://github.com/lobehub/skill-repo
|
||||
lh skill install lobehub/skill-repo --branch dev
|
||||
|
||||
# ZIP URL
|
||||
lh skill install https://example.com/skill.zip
|
||||
|
||||
# Marketplace identifier
|
||||
lh skill install my-cool-skill
|
||||
lh skill i my-cool-skill
|
||||
```
|
||||
|
||||
| Option | Description | Notes |
|
||||
| ------------------- | ------------------------- | -------- |
|
||||
| `--branch <branch>` | Branch name (GitHub only) | Optional |
|
||||
|
||||
**Detection rules**:
|
||||
|
||||
- `https://github.com/...` or `owner/repo` → GitHub
|
||||
- Other `https://...` URLs → ZIP URL
|
||||
- Everything else → marketplace identifier
|
||||
|
||||
### Resource Commands
|
||||
|
||||
#### `lh skill resources <id>`
|
||||
|
||||
List files/resources within a skill.
|
||||
|
||||
```bash
|
||||
lh skill resources [fields]] < id > [--json
|
||||
```
|
||||
|
||||
**Displays**: Path, type, size.
|
||||
|
||||
#### `lh skill read-resource <id> <path>`
|
||||
|
||||
Read a specific resource file from a skill.
|
||||
|
||||
```bash
|
||||
lh skill read-resource <skillId> <path>
|
||||
```
|
||||
|
||||
**Output**: File content or JSON metadata.
|
||||
|
||||
---
|
||||
|
||||
## Plugin Management (`lh plugin`)
|
||||
|
||||
Install and manage plugins (external tool integrations).
|
||||
|
||||
**Source**: `apps/cli/src/commands/plugin.ts`
|
||||
|
||||
### `lh plugin list`
|
||||
|
||||
```bash
|
||||
lh plugin list [--json [fields]]
|
||||
```
|
||||
|
||||
**Table columns**: ID, IDENTIFIER, TYPE, TITLE
|
||||
|
||||
### `lh plugin install`
|
||||
|
||||
```bash
|
||||
lh plugin install -i [--settings < identifier > --manifest < json > [--type < type > ] < json > ]
|
||||
```
|
||||
|
||||
| Option | Description | Required |
|
||||
| ----------------------- | -------------------------- | ---------------------- |
|
||||
| `-i, --identifier <id>` | Plugin identifier | Yes |
|
||||
| `--manifest <json>` | Plugin manifest JSON | Yes |
|
||||
| `--type <type>` | `plugin` or `customPlugin` | No (default: `plugin`) |
|
||||
| `--settings <json>` | Plugin settings JSON | No |
|
||||
|
||||
### `lh plugin uninstall <id>`
|
||||
|
||||
```bash
|
||||
lh plugin uninstall < id > [--yes]
|
||||
```
|
||||
|
||||
### `lh plugin update <id>`
|
||||
|
||||
```bash
|
||||
lh plugin update [--settings < id > [--manifest < json > ] < json > ]
|
||||
```
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
name: response-compliance
|
||||
description: OpenResponses API compliance testing. Use when testing the Response API endpoint, running compliance tests, or debugging Response API schema issues. Triggers on 'compliance', 'response api test', 'openresponses test'.
|
||||
---
|
||||
|
||||
# OpenResponses Compliance Test
|
||||
|
||||
Run the official OpenResponses compliance test suite against the local (or remote) Response API endpoint.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# From the openapi package directory
|
||||
cd lobehub/packages/openapi
|
||||
|
||||
# Run all tests (dev mode, localhost:3010)
|
||||
APP_URL=http://localhost:3010 bun run test:response-compliance -- \
|
||||
--auth-header "lobe-auth-dev-backend-api" --no-bearer --api-key 1
|
||||
|
||||
# Run specific tests only
|
||||
APP_URL=http://localhost:3010 bun run test:response-compliance -- \
|
||||
--auth-header "lobe-auth-dev-backend-api" --no-bearer --api-key 1 \
|
||||
--filter basic-response,streaming-response
|
||||
|
||||
# Verbose mode (shows request/response details)
|
||||
APP_URL=http://localhost:3010 bun run test:response-compliance -- \
|
||||
--auth-header "lobe-auth-dev-backend-api" --no-bearer --api-key 1 -v
|
||||
|
||||
# JSON output (for CI)
|
||||
APP_URL=http://localhost:3010 bun run test:response-compliance -- \
|
||||
--auth-header "lobe-auth-dev-backend-api" --no-bearer --api-key 1 --json
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Dev server running with `ENABLE_MOCK_DEV_USER=true` in `.env`
|
||||
- The `api/v1/responses` route registered (via `src/app/(backend)/api/v1/[[...route]]/route.ts`)
|
||||
|
||||
## Auth Modes
|
||||
|
||||
| Mode | Flags |
|
||||
| --------------- | ------------------------------------------------------------------- |
|
||||
| Dev (mock user) | `--auth-header "lobe-auth-dev-backend-api" --no-bearer --api-key 1` |
|
||||
| API Key | `--api-key lb-xxxxxxxxxxxxxxxx` |
|
||||
| Custom | `--auth-header <name> --api-key <value>` |
|
||||
|
||||
## Test IDs
|
||||
|
||||
Available `--filter` values:
|
||||
|
||||
| ID | Description | Related Issue |
|
||||
| -------------------- | -------------------------------------- | ------------- |
|
||||
| `basic-response` | Simple text generation (non-streaming) | LOBE-5858 |
|
||||
| `streaming-response` | SSE streaming lifecycle + events | LOBE-5859 |
|
||||
| `system-prompt` | System role message handling | LOBE-5858 |
|
||||
| `tool-calling` | Function tool definition + call output | LOBE-5860 |
|
||||
| `image-input` | Multimodal image URL content | — |
|
||||
| `multi-turn` | Conversation history via input items | LOBE-5861 |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
| --------- | ----------------------- | ----------------------------------------- |
|
||||
| `APP_URL` | `http://localhost:3010` | Server base URL (auto-appends `/api/v1`) |
|
||||
| `API_KEY` | — | API key (alternative to `--api-key` flag) |
|
||||
|
||||
## How It Works
|
||||
|
||||
The script (`lobehub/packages/openapi/scripts/compliance-test.sh`) clones the official [openresponses/openresponses](https://github.com/openresponses/openresponses) repo into `scripts/openresponses-compliance/` (gitignored) and runs its CLI test runner. First run clones; subsequent runs update from upstream.
|
||||
|
||||
## Debugging Failures
|
||||
|
||||
1. Run with `-v` to see full request/response payloads
|
||||
2. Common failure patterns:
|
||||
- **"Failed to parse JSON"**: Auth failed, server returned HTML redirect
|
||||
- **"Response has no output items"**: LLM execution not yet implemented
|
||||
- **"Expected number, received null"**: Missing required field in response schema
|
||||
- **"Invalid input"**: Zod validation on response schema — check field format
|
||||
|
||||
## Key Files
|
||||
|
||||
- **Types**: `lobehub/packages/openapi/src/types/responses.type.ts`
|
||||
- **Service**: `lobehub/packages/openapi/src/services/responses.service.ts`
|
||||
- **Controller**: `lobehub/packages/openapi/src/controllers/responses.controller.ts`
|
||||
- **Route**: `lobehub/packages/openapi/src/routes/responses.route.ts`
|
||||
- **Test script**: `lobehub/packages/openapi/scripts/compliance-test.sh`
|
||||
- **Cloud route**: `src/app/(backend)/api/v1/[[...route]]/route.ts`
|
||||
@@ -50,3 +50,4 @@ description: TypeScript code style and optimization guidelines. Use when writing
|
||||
- Never log user private information (API keys, etc.)
|
||||
- Don't use `import { log } from 'debug'` directly (logs to console)
|
||||
- Use `console.error` in catch blocks instead of debug package
|
||||
- Always log the error in `.catch()` callbacks — silent `.catch(() => fallback)` swallows failures and makes debugging impossible
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* E2E tests for `lh agent` agent management commands.
|
||||
*
|
||||
* Prerequisites:
|
||||
* - `lh` CLI is installed and linked globally
|
||||
* - User is authenticated (`lh login` completed)
|
||||
* - Network access to the LobeHub server
|
||||
*
|
||||
* These tests create a real agent, verify CRUD operations, then clean up.
|
||||
* Note: `agent run` and `agent status` are not tested here as they require
|
||||
* active SSE connections and running agents.
|
||||
*/
|
||||
|
||||
const CLI = process.env.LH_CLI_PATH || 'lh';
|
||||
const TIMEOUT = 30_000;
|
||||
|
||||
function run(args: string): string {
|
||||
return execSync(`${CLI} ${args}`, {
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, PATH: `${process.env.HOME}/.bun/bin:${process.env.PATH}` },
|
||||
timeout: TIMEOUT,
|
||||
}).trim();
|
||||
}
|
||||
|
||||
function runJson<T = any>(args: string): T {
|
||||
const output = run(args);
|
||||
return JSON.parse(output) as T;
|
||||
}
|
||||
|
||||
describe('lh agent - E2E', () => {
|
||||
const testTitle = `E2E-Agent-${Date.now()}`;
|
||||
const testDescription = 'Created by E2E test';
|
||||
let createdId: string;
|
||||
|
||||
// ── list ──────────────────────────────────────────────
|
||||
|
||||
describe('list', () => {
|
||||
it('should list agents in table format', () => {
|
||||
const output = run('agent list');
|
||||
expect(output).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should output JSON', () => {
|
||||
const list = runJson<any[]>('agent list --json id,title');
|
||||
expect(Array.isArray(list)).toBe(true);
|
||||
});
|
||||
|
||||
it('should respect limit option', () => {
|
||||
const list = runJson<any[]>('agent list --json id -L 3');
|
||||
expect(list.length).toBeLessThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
// ── create ────────────────────────────────────────────
|
||||
|
||||
describe('create', () => {
|
||||
it('should create an agent', () => {
|
||||
const output = run(`agent create -t "${testTitle}" -d "${testDescription}"`);
|
||||
expect(output).toContain('Created agent');
|
||||
|
||||
const match = output.match(/Created agent\s+(\S+)/);
|
||||
expect(match).not.toBeNull();
|
||||
createdId = match![1];
|
||||
});
|
||||
});
|
||||
|
||||
// ── view ──────────────────────────────────────────────
|
||||
|
||||
describe('view', () => {
|
||||
it('should view agent details', () => {
|
||||
const output = run(`agent view ${createdId}`);
|
||||
expect(output).toContain(testTitle);
|
||||
});
|
||||
|
||||
it('should output JSON', () => {
|
||||
const result = runJson<{ title: string }>(`agent view ${createdId} --json title,description`);
|
||||
expect(result.title).toBe(testTitle);
|
||||
});
|
||||
});
|
||||
|
||||
// ── edit ──────────────────────────────────────────────
|
||||
|
||||
describe('edit', () => {
|
||||
const updatedTitle = `${testTitle}-Updated`;
|
||||
|
||||
it('should update agent title', () => {
|
||||
const output = run(`agent edit ${createdId} -t "${updatedTitle}"`);
|
||||
expect(output).toContain('Updated agent');
|
||||
});
|
||||
|
||||
it('should reflect updates when viewed', () => {
|
||||
const result = runJson<{ title: string }>(`agent view ${createdId} --json title`);
|
||||
expect(result.title).toBe(updatedTitle);
|
||||
});
|
||||
|
||||
it('should error when no changes specified', () => {
|
||||
expect(() => run(`agent edit ${createdId}`)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── duplicate ─────────────────────────────────────────
|
||||
|
||||
describe('duplicate', () => {
|
||||
let duplicatedId: string;
|
||||
|
||||
it('should duplicate an agent', () => {
|
||||
const output = run(`agent duplicate ${createdId}`);
|
||||
expect(output).toContain('Duplicated agent');
|
||||
|
||||
const match = output.match(/→\s+(\S+)/);
|
||||
if (match) duplicatedId = match[1];
|
||||
});
|
||||
|
||||
it('should clean up duplicate', () => {
|
||||
if (duplicatedId) {
|
||||
const output = run(`agent delete ${duplicatedId} --yes`);
|
||||
expect(output).toContain('Deleted agent');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── delete (cleanup) ──────────────────────────────────
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete the agent', () => {
|
||||
const output = run(`agent delete ${createdId} --yes`);
|
||||
expect(output).toContain('Deleted agent');
|
||||
expect(output).toContain(createdId);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,286 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* E2E tests for `lh doc` document management commands.
|
||||
*
|
||||
* Prerequisites:
|
||||
* - `lh` CLI is installed and linked globally
|
||||
* - User is authenticated (`lh login` completed)
|
||||
* - Network access to the LobeHub server
|
||||
*
|
||||
* These tests create real documents, verify CRUD operations, then clean up.
|
||||
*/
|
||||
|
||||
const CLI = process.env.LH_CLI_PATH || 'lh';
|
||||
const TIMEOUT = 30_000;
|
||||
|
||||
function run(args: string): string {
|
||||
return execSync(`${CLI} ${args}`, {
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, PATH: `${process.env.HOME}/.bun/bin:${process.env.PATH}` },
|
||||
timeout: TIMEOUT,
|
||||
}).trim();
|
||||
}
|
||||
|
||||
function runJson<T = any>(args: string): T {
|
||||
const output = run(args);
|
||||
return JSON.parse(output) as T;
|
||||
}
|
||||
|
||||
function extractDocId(output: string): string {
|
||||
const idMatch = output.match(/(docs_\w+)/);
|
||||
expect(idMatch).not.toBeNull();
|
||||
return idMatch![1];
|
||||
}
|
||||
|
||||
describe('lh doc - E2E', () => {
|
||||
const testTitle = `E2E-Doc-${Date.now()}`;
|
||||
const testBody = 'Created by E2E test';
|
||||
let createdId: string;
|
||||
|
||||
// ── create ────────────────────────────────────────────
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a document with title and body', () => {
|
||||
const output = run(`doc create -t "${testTitle}" -b "${testBody}"`);
|
||||
expect(output).toContain('Created document');
|
||||
createdId = extractDocId(output);
|
||||
});
|
||||
|
||||
it('should appear in the list', () => {
|
||||
const list = runJson<{ id: string; title: string }[]>('doc list --json id,title');
|
||||
const found = list.find((d) => d.id === createdId);
|
||||
expect(found).toBeDefined();
|
||||
expect(found!.title).toBe(testTitle);
|
||||
});
|
||||
});
|
||||
|
||||
// ── list ──────────────────────────────────────────────
|
||||
|
||||
describe('list', () => {
|
||||
it('should list documents in table format', () => {
|
||||
const output = run('doc list');
|
||||
expect(output).toContain('ID');
|
||||
expect(output).toContain('TITLE');
|
||||
});
|
||||
|
||||
it('should output JSON with field filtering', () => {
|
||||
const list = runJson<{ id: string; title: string }[]>('doc list --json id,title');
|
||||
expect(Array.isArray(list)).toBe(true);
|
||||
expect(list.length).toBeGreaterThan(0);
|
||||
const first = list[0];
|
||||
expect(first).toHaveProperty('id');
|
||||
expect(first).toHaveProperty('title');
|
||||
expect(first).not.toHaveProperty('content');
|
||||
});
|
||||
|
||||
it('should respect --limit flag', () => {
|
||||
const list = runJson<any[]>('doc list --json id -L 1');
|
||||
expect(list.length).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('should filter by --file-type', () => {
|
||||
const output = run('doc list --file-type custom/document --json id');
|
||||
const list = JSON.parse(output);
|
||||
expect(Array.isArray(list)).toBe(true);
|
||||
});
|
||||
|
||||
it('should filter by --source-type', () => {
|
||||
const output = run('doc list --source-type api --json id');
|
||||
const list = JSON.parse(output);
|
||||
expect(Array.isArray(list)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── view ──────────────────────────────────────────────
|
||||
|
||||
describe('view', () => {
|
||||
it('should view document details', () => {
|
||||
const output = run(`doc view ${createdId}`);
|
||||
expect(output).toContain(testTitle);
|
||||
});
|
||||
|
||||
it('should output JSON with --json flag', () => {
|
||||
const result = runJson<{ id: string; title: string }>(
|
||||
`doc view ${createdId} --json id,title`,
|
||||
);
|
||||
expect(result.id).toBe(createdId);
|
||||
expect(result.title).toBe(testTitle);
|
||||
});
|
||||
});
|
||||
|
||||
// ── edit ──────────────────────────────────────────────
|
||||
|
||||
describe('edit', () => {
|
||||
const updatedTitle = `${testTitle}-Updated`;
|
||||
const updatedBody = 'Updated by E2E test';
|
||||
|
||||
it('should update document title', () => {
|
||||
const output = run(`doc edit ${createdId} -t "${updatedTitle}"`);
|
||||
expect(output).toContain('Updated document');
|
||||
expect(output).toContain(createdId);
|
||||
});
|
||||
|
||||
it('should reflect title update when viewed', () => {
|
||||
const result = runJson<{ title: string }>(`doc view ${createdId} --json title`);
|
||||
expect(result.title).toBe(updatedTitle);
|
||||
});
|
||||
|
||||
it('should update document body', () => {
|
||||
const output = run(`doc edit ${createdId} -b "${updatedBody}"`);
|
||||
expect(output).toContain('Updated document');
|
||||
});
|
||||
|
||||
it('should reflect body update when viewed', () => {
|
||||
const result = runJson<{ content: string }>(`doc view ${createdId} --json content`);
|
||||
expect(result.content).toBe(updatedBody);
|
||||
});
|
||||
|
||||
it('should update body from file with --body-file', () => {
|
||||
const tmpFile = path.join(os.tmpdir(), `e2e-doc-body-${Date.now()}.md`);
|
||||
fs.writeFileSync(tmpFile, '# File Content\nFrom body-file flag');
|
||||
|
||||
try {
|
||||
const output = run(`doc edit ${createdId} -F "${tmpFile}"`);
|
||||
expect(output).toContain('Updated document');
|
||||
|
||||
const result = runJson<{ content: string }>(`doc view ${createdId} --json content`);
|
||||
expect(result.content).toContain('File Content');
|
||||
} finally {
|
||||
fs.unlinkSync(tmpFile);
|
||||
}
|
||||
});
|
||||
|
||||
it('should update file type with --file-type', () => {
|
||||
const output = run(`doc edit ${createdId} --file-type custom/document`);
|
||||
expect(output).toContain('Updated document');
|
||||
|
||||
const result = runJson<{ fileType: string }>(`doc view ${createdId} --json fileType`);
|
||||
expect(result.fileType).toBe('custom/document');
|
||||
});
|
||||
|
||||
it('should error when no changes specified', () => {
|
||||
expect(() => run(`doc edit ${createdId}`)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── create with options ────────────────────────────────
|
||||
|
||||
describe('create with options', () => {
|
||||
let childDocId: string;
|
||||
|
||||
it('should create a document with --slug', () => {
|
||||
const slug = `e2e-slug-${Date.now()}`;
|
||||
const output = run(`doc create -t "E2E-Slug-Doc" --slug "${slug}"`);
|
||||
expect(output).toContain('Created document');
|
||||
childDocId = extractDocId(output);
|
||||
});
|
||||
|
||||
it('should create a document with --file-type', () => {
|
||||
const output = run(`doc create -t "E2E-Typed-Doc" --file-type custom/document`);
|
||||
expect(output).toContain('Created document');
|
||||
const id = extractDocId(output);
|
||||
|
||||
const result = runJson<{ fileType: string }>(`doc view ${id} --json fileType`);
|
||||
expect(result.fileType).toBe('custom/document');
|
||||
|
||||
run(`doc delete ${id} --yes`);
|
||||
});
|
||||
|
||||
it('should create a document from file with --body-file', () => {
|
||||
const tmpFile = path.join(os.tmpdir(), `e2e-doc-create-${Date.now()}.md`);
|
||||
fs.writeFileSync(tmpFile, '# Created from file\nTest content');
|
||||
|
||||
try {
|
||||
const output = run(`doc create -t "E2E-FromFile" -F "${tmpFile}"`);
|
||||
expect(output).toContain('Created document');
|
||||
const id = extractDocId(output);
|
||||
run(`doc delete ${id} --yes`);
|
||||
} finally {
|
||||
fs.unlinkSync(tmpFile);
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up the slug doc
|
||||
it('should clean up slug doc', () => {
|
||||
if (childDocId) {
|
||||
const output = run(`doc delete ${childDocId} --yes`);
|
||||
expect(output).toContain('Deleted');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── batch-create ──────────────────────────────────────
|
||||
|
||||
describe('batch-create', () => {
|
||||
let batchDocIds: string[] = [];
|
||||
|
||||
it('should batch create documents from JSON file', () => {
|
||||
const tmpFile = path.join(os.tmpdir(), `e2e-batch-${Date.now()}.json`);
|
||||
const docs = [
|
||||
{ title: `E2E-Batch-1-${Date.now()}`, content: 'batch content 1' },
|
||||
{ title: `E2E-Batch-2-${Date.now()}`, content: 'batch content 2' },
|
||||
];
|
||||
fs.writeFileSync(tmpFile, JSON.stringify(docs));
|
||||
|
||||
try {
|
||||
const output = run(`doc batch-create "${tmpFile}"`);
|
||||
expect(output).toContain('Created 2 document(s)');
|
||||
|
||||
// Extract IDs from output
|
||||
const matches = output.matchAll(/(docs_\w+)/g);
|
||||
batchDocIds = [...matches].map((m) => m[1]);
|
||||
expect(batchDocIds.length).toBe(2);
|
||||
} finally {
|
||||
fs.unlinkSync(tmpFile);
|
||||
}
|
||||
});
|
||||
|
||||
it('should clean up batch created docs', () => {
|
||||
if (batchDocIds.length > 0) {
|
||||
const output = run(`doc delete ${batchDocIds.join(' ')} --yes`);
|
||||
expect(output).toContain('Deleted');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── delete (cleanup) ──────────────────────────────────
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete the document', () => {
|
||||
const output = run(`doc delete ${createdId} --yes`);
|
||||
expect(output).toContain('Deleted');
|
||||
});
|
||||
|
||||
it('should no longer appear in the list', () => {
|
||||
const list = runJson<{ id: string }[]>('doc list --json id');
|
||||
const found = list.find((d) => d.id === createdId);
|
||||
expect(found).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── delete multiple ───────────────────────────────────
|
||||
|
||||
describe('delete multiple', () => {
|
||||
let docId1: string;
|
||||
let docId2: string;
|
||||
|
||||
it('should create two documents for batch delete', () => {
|
||||
const output1 = run(`doc create -t "E2E-BatchDel-1" -b "batch test 1"`);
|
||||
docId1 = extractDocId(output1);
|
||||
|
||||
const output2 = run(`doc create -t "E2E-BatchDel-2" -b "batch test 2"`);
|
||||
docId2 = extractDocId(output2);
|
||||
});
|
||||
|
||||
it('should delete multiple documents at once', () => {
|
||||
const output = run(`doc delete ${docId1} ${docId2} --yes`);
|
||||
expect(output).toContain('Deleted 2');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* E2E tests for `lh file` file management commands.
|
||||
*
|
||||
* Prerequisites:
|
||||
* - `lh` CLI is installed and linked globally
|
||||
* - User is authenticated (`lh login` completed)
|
||||
* - Network access to the LobeHub server
|
||||
*/
|
||||
|
||||
const CLI = process.env.LH_CLI_PATH || 'lh';
|
||||
const TIMEOUT = 30_000;
|
||||
|
||||
function run(args: string): string {
|
||||
return execSync(`${CLI} ${args}`, {
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, PATH: `${process.env.HOME}/.bun/bin:${process.env.PATH}` },
|
||||
timeout: TIMEOUT,
|
||||
}).trim();
|
||||
}
|
||||
|
||||
function runJson<T = any>(args: string): T {
|
||||
const output = run(args);
|
||||
return JSON.parse(output) as T;
|
||||
}
|
||||
|
||||
describe('lh file - E2E', () => {
|
||||
// ── list ──────────────────────────────────────────────
|
||||
|
||||
describe('list', () => {
|
||||
it('should list files in table format', () => {
|
||||
const output = run('file list');
|
||||
// Either table or "No files found."
|
||||
expect(output).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should output JSON', () => {
|
||||
const list = runJson<any[]>('file list --json id,name');
|
||||
expect(Array.isArray(list)).toBe(true);
|
||||
if (list.length > 0) {
|
||||
expect(list[0]).toHaveProperty('id');
|
||||
expect(list[0]).toHaveProperty('name');
|
||||
}
|
||||
});
|
||||
|
||||
it('should accept limit option', () => {
|
||||
// Backend may not strictly enforce limit; verify it doesn't error
|
||||
const list = runJson<any[]>('file list --json id -L 5');
|
||||
expect(Array.isArray(list)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── view ──────────────────────────────────────────────
|
||||
|
||||
describe('view', () => {
|
||||
it('should show file details if files exist', () => {
|
||||
const list = runJson<{ id: string }[]>('file list --json id -L 1');
|
||||
if (list.length > 0) {
|
||||
const output = run(`file view ${list[0].id}`);
|
||||
expect(output).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it('should output JSON for file detail', () => {
|
||||
const list = runJson<{ id: string }[]>('file list --json id -L 1');
|
||||
if (list.length > 0) {
|
||||
const result = runJson(`file view ${list[0].id} --json id,name`);
|
||||
expect(result).toHaveProperty('id');
|
||||
}
|
||||
});
|
||||
|
||||
it('should error for nonexistent file', () => {
|
||||
expect(() => run('file view nonexistent-file-xyz')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── recent ────────────────────────────────────────────
|
||||
|
||||
describe('recent', () => {
|
||||
it('should list recent files', () => {
|
||||
const output = run('file recent');
|
||||
expect(output).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should output JSON', () => {
|
||||
const list = runJson<any[]>('file recent --json id,name');
|
||||
expect(Array.isArray(list)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* E2E tests for `lh generate` (alias `lh gen`) content generation commands.
|
||||
*
|
||||
* Prerequisites:
|
||||
* - `lh` CLI is installed and linked globally
|
||||
* - User is authenticated (`lh login` completed)
|
||||
* - Network access to the LobeHub server
|
||||
*/
|
||||
|
||||
const CLI = process.env.LH_CLI_PATH || 'lh';
|
||||
const TIMEOUT = 30_000;
|
||||
|
||||
function run(args: string): string {
|
||||
return execSync(`${CLI} ${args}`, {
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, PATH: `${process.env.HOME}/.bun/bin:${process.env.PATH}` },
|
||||
timeout: TIMEOUT,
|
||||
}).trim();
|
||||
}
|
||||
|
||||
function runJson<T = any>(args: string): T {
|
||||
const output = run(args);
|
||||
return JSON.parse(output) as T;
|
||||
}
|
||||
|
||||
describe('lh generate - E2E', () => {
|
||||
// ── text ──────────────────────────────────────────────
|
||||
|
||||
describe('text', () => {
|
||||
it('should generate text (non-streaming, default model)', () => {
|
||||
const output = run('gen text "Reply with just the word OK"');
|
||||
expect(output).toBeTruthy();
|
||||
expect(output.length).toBeGreaterThan(0);
|
||||
}, 60_000);
|
||||
|
||||
it('should generate text with --json flag', () => {
|
||||
const output = run('gen text "Reply with just the word OK" --json');
|
||||
const parsed = JSON.parse(output);
|
||||
// OpenAI format
|
||||
expect(parsed).toHaveProperty('model');
|
||||
expect(parsed.choices?.[0]?.message?.content || parsed.content?.[0]?.text).toBeTruthy();
|
||||
}, 60_000);
|
||||
|
||||
it('should generate text with system prompt', () => {
|
||||
const output = run('gen text "Say hello" -s "You must reply in French only"');
|
||||
expect(output).toBeTruthy();
|
||||
}, 60_000);
|
||||
|
||||
it('should generate text with --stream flag', () => {
|
||||
const output = run('gen text "Reply with just the word OK" --stream');
|
||||
expect(output).toBeTruthy();
|
||||
}, 60_000);
|
||||
|
||||
it('should generate text with custom model', () => {
|
||||
const output = run('gen text "Reply with just OK" -m "openai/gpt-4o-mini"');
|
||||
expect(output).toBeTruthy();
|
||||
}, 60_000);
|
||||
|
||||
it('should generate text with temperature option', () => {
|
||||
const output = run('gen text "Reply with just the number 42" --temperature 0');
|
||||
expect(output).toContain('42');
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
// ── list ──────────────────────────────────────────────
|
||||
|
||||
describe('list', () => {
|
||||
it('should list generation topics in table format', () => {
|
||||
const output = run('gen list');
|
||||
// May have topics or show empty message
|
||||
expect(output).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should list generation topics with --json', () => {
|
||||
const output = run('gen list --json');
|
||||
const parsed = JSON.parse(output);
|
||||
expect(Array.isArray(parsed)).toBe(true);
|
||||
});
|
||||
|
||||
it('should filter JSON fields', () => {
|
||||
const items = runJson<any[]>('gen list --json id,type');
|
||||
if (items.length > 0) {
|
||||
expect(items[0]).toHaveProperty('id');
|
||||
expect(items[0]).toHaveProperty('type');
|
||||
expect(items[0]).not.toHaveProperty('title');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── tts ───────────────────────────────────────────────
|
||||
|
||||
describe('tts', () => {
|
||||
it('should reject invalid backend', () => {
|
||||
expect(() => run('gen tts "hello" --backend invalid')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── asr ───────────────────────────────────────────────
|
||||
|
||||
describe('asr', () => {
|
||||
it('should reject non-existent audio file', () => {
|
||||
expect(() => run('gen asr /tmp/nonexistent-audio.mp3')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── alias ─────────────────────────────────────────────
|
||||
|
||||
describe('alias', () => {
|
||||
it('should work with "generate" (full name) as well as "gen"', () => {
|
||||
const output = run('generate list --json');
|
||||
const parsed = JSON.parse(output);
|
||||
expect(Array.isArray(parsed)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* E2E tests for `lh kb` knowledge base management commands.
|
||||
*
|
||||
* Prerequisites:
|
||||
* - `lh` CLI is installed and linked globally
|
||||
* - User is authenticated (`lh login` completed)
|
||||
* - Network access to the LobeHub server
|
||||
*
|
||||
* These tests create a real knowledge base, verify CRUD operations, then clean up.
|
||||
*/
|
||||
|
||||
const CLI = process.env.LH_CLI_PATH || 'lh';
|
||||
const TIMEOUT = 30_000;
|
||||
|
||||
function run(args: string): string {
|
||||
return execSync(`${CLI} ${args}`, {
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, PATH: `${process.env.HOME}/.bun/bin:${process.env.PATH}` },
|
||||
timeout: TIMEOUT,
|
||||
}).trim();
|
||||
}
|
||||
|
||||
function runJson<T = any>(args: string): T {
|
||||
const output = run(args);
|
||||
return JSON.parse(output) as T;
|
||||
}
|
||||
|
||||
function extractId(output: string, prefix: string): string {
|
||||
const re = new RegExp(`${prefix}\\w+`);
|
||||
const match = output.match(re);
|
||||
expect(match).not.toBeNull();
|
||||
return match![0];
|
||||
}
|
||||
|
||||
describe('lh kb - E2E', () => {
|
||||
const testName = `E2E-Test-${Date.now()}`;
|
||||
const testDescription = 'Created by E2E test';
|
||||
let createdId: string;
|
||||
|
||||
// ── create ────────────────────────────────────────────
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a knowledge base and return its id', () => {
|
||||
const output = run(`kb create -n "${testName}" -d "${testDescription}"`);
|
||||
expect(output).toContain('Created knowledge base');
|
||||
createdId = extractId(output, 'kb_');
|
||||
});
|
||||
|
||||
it('should appear in the list', () => {
|
||||
const list = runJson<{ id: string; name: string }[]>('kb list --json id,name');
|
||||
const found = list.find((kb) => kb.id === createdId);
|
||||
expect(found).toBeDefined();
|
||||
expect(found!.name).toBe(testName);
|
||||
});
|
||||
});
|
||||
|
||||
// ── list ──────────────────────────────────────────────
|
||||
|
||||
describe('list', () => {
|
||||
it('should list knowledge bases in table format', () => {
|
||||
const output = run('kb list');
|
||||
expect(output).toContain('ID');
|
||||
expect(output).toContain('NAME');
|
||||
});
|
||||
|
||||
it('should output JSON with field filtering', () => {
|
||||
const list = runJson<{ id: string; name: string }[]>('kb list --json id,name');
|
||||
expect(Array.isArray(list)).toBe(true);
|
||||
expect(list.length).toBeGreaterThan(0);
|
||||
const first = list[0];
|
||||
expect(first).toHaveProperty('id');
|
||||
expect(first).toHaveProperty('name');
|
||||
expect(first).not.toHaveProperty('description');
|
||||
});
|
||||
});
|
||||
|
||||
// ── view ──────────────────────────────────────────────
|
||||
|
||||
describe('view', () => {
|
||||
it('should view knowledge base details', () => {
|
||||
const output = run(`kb view ${createdId}`);
|
||||
expect(output).toContain(testName);
|
||||
expect(output).toContain(testDescription);
|
||||
});
|
||||
|
||||
it('should output JSON with --json flag', () => {
|
||||
const result = runJson<{ description: string; id: string; name: string }>(
|
||||
`kb view ${createdId} --json id,name,description`,
|
||||
);
|
||||
expect(result.id).toBe(createdId);
|
||||
expect(result.name).toBe(testName);
|
||||
expect(result.description).toBe(testDescription);
|
||||
});
|
||||
});
|
||||
|
||||
// ── edit ──────────────────────────────────────────────
|
||||
|
||||
describe('edit', () => {
|
||||
const updatedName = `${testName}-Updated`;
|
||||
const updatedDesc = 'Updated by E2E test';
|
||||
|
||||
it('should update knowledge base name and description', () => {
|
||||
const output = run(`kb edit ${createdId} -n "${updatedName}" -d "${updatedDesc}"`);
|
||||
expect(output).toContain('Updated knowledge base');
|
||||
expect(output).toContain(createdId);
|
||||
});
|
||||
|
||||
it('should reflect updates when viewed', () => {
|
||||
const result = runJson<{ description: string; name: string }>(
|
||||
`kb view ${createdId} --json name,description`,
|
||||
);
|
||||
expect(result.name).toBe(updatedName);
|
||||
expect(result.description).toBe(updatedDesc);
|
||||
});
|
||||
|
||||
it('should error when no changes specified', () => {
|
||||
expect(() => run(`kb edit ${createdId}`)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── mkdir ─────────────────────────────────────────────
|
||||
|
||||
describe('mkdir', () => {
|
||||
let folderId: string;
|
||||
|
||||
it('should create a folder in the knowledge base', () => {
|
||||
const output = run(`kb mkdir ${createdId} -n "E2E-Folder"`);
|
||||
expect(output).toContain('Created folder');
|
||||
folderId = extractId(output, 'docs_');
|
||||
});
|
||||
|
||||
it('should appear in kb view', () => {
|
||||
const output = run(`kb view ${createdId}`);
|
||||
expect(output).toContain('E2E-Folder');
|
||||
expect(output).toContain('folder');
|
||||
});
|
||||
|
||||
it('should create a nested folder', () => {
|
||||
const output = run(`kb mkdir ${createdId} -n "E2E-SubFolder" --parent ${folderId}`);
|
||||
expect(output).toContain('Created folder');
|
||||
});
|
||||
});
|
||||
|
||||
// ── create-doc ────────────────────────────────────────
|
||||
|
||||
describe('create-doc', () => {
|
||||
let docId: string;
|
||||
let folderId: string;
|
||||
|
||||
it('should create a document at root', () => {
|
||||
const output = run(`kb create-doc ${createdId} -t "E2E-Doc" -c "test content"`);
|
||||
expect(output).toContain('Created document');
|
||||
docId = extractId(output, 'docs_');
|
||||
});
|
||||
|
||||
it('should create a document inside a folder', () => {
|
||||
// First get the folder id
|
||||
const viewOutput = run(`kb view ${createdId}`);
|
||||
// eslint-disable-next-line regexp/no-super-linear-backtracking,regexp/optimal-quantifier-concatenation
|
||||
const folderMatch = viewOutput.match(/(docs_\w+).*E2E-Folder/);
|
||||
expect(folderMatch).not.toBeNull();
|
||||
folderId = folderMatch![1];
|
||||
|
||||
const output = run(`kb create-doc ${createdId} -t "E2E-NestedDoc" --parent ${folderId}`);
|
||||
expect(output).toContain('Created document');
|
||||
});
|
||||
|
||||
it('should show documents in kb view', () => {
|
||||
const output = run(`kb view ${createdId}`);
|
||||
expect(output).toContain('E2E-Doc');
|
||||
expect(output).toContain('E2E-NestedDoc');
|
||||
});
|
||||
});
|
||||
|
||||
// ── move ──────────────────────────────────────────────
|
||||
|
||||
describe('move', () => {
|
||||
let docId: string;
|
||||
let folderId: string;
|
||||
|
||||
it('should move a document into a folder', () => {
|
||||
// Get doc and folder IDs from view
|
||||
const result = runJson<{ files: { fileType: string; id: string; name: string }[] }>(
|
||||
`kb view ${createdId} --json files`,
|
||||
);
|
||||
const doc = result.files.find((f) => f.name === 'E2E-Doc');
|
||||
const folder = result.files.find(
|
||||
(f) => f.fileType === 'custom/folder' && f.name === 'E2E-Folder',
|
||||
);
|
||||
expect(doc).toBeDefined();
|
||||
expect(folder).toBeDefined();
|
||||
docId = doc!.id;
|
||||
folderId = folder!.id;
|
||||
|
||||
const output = run(`kb move ${docId} --type doc --parent ${folderId}`);
|
||||
expect(output).toContain('Moved');
|
||||
expect(output).toContain(folderId);
|
||||
});
|
||||
|
||||
it('should move a document back to root', () => {
|
||||
const output = run(`kb move ${docId} --type doc`);
|
||||
expect(output).toContain('Moved');
|
||||
expect(output).toContain('root');
|
||||
});
|
||||
});
|
||||
|
||||
// ── upload ────────────────────────────────────────────
|
||||
|
||||
describe('upload', () => {
|
||||
let tmpFile: string;
|
||||
|
||||
it('should upload a file to the knowledge base', () => {
|
||||
tmpFile = path.join(os.tmpdir(), `e2e-upload-${Date.now()}.txt`);
|
||||
fs.writeFileSync(tmpFile, 'E2E upload test content');
|
||||
|
||||
const output = run(`kb upload ${createdId} ${tmpFile}`);
|
||||
expect(output).toContain('Uploaded');
|
||||
expect(output).toMatch(/file_\w+/);
|
||||
|
||||
fs.unlinkSync(tmpFile);
|
||||
});
|
||||
|
||||
it('should show uploaded file in kb view', () => {
|
||||
const output = run(`kb view ${createdId}`);
|
||||
expect(output).toContain('e2e-upload');
|
||||
expect(output).toContain('txt');
|
||||
});
|
||||
});
|
||||
|
||||
// ── delete (cleanup) ──────────────────────────────────
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete the knowledge base', () => {
|
||||
const output = run(`kb delete ${createdId} --yes`);
|
||||
expect(output).toContain('Deleted knowledge base');
|
||||
expect(output).toContain(createdId);
|
||||
});
|
||||
|
||||
it('should no longer appear in the list', () => {
|
||||
const list = runJson<{ id: string }[]>('kb list --json id');
|
||||
const found = list.find((kb) => kb.id === createdId);
|
||||
expect(found).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* E2E tests for `lh memory` user memory management commands.
|
||||
*
|
||||
* Prerequisites:
|
||||
* - `lh` CLI is installed and linked globally
|
||||
* - User is authenticated (`lh login` completed)
|
||||
* - Network access to the LobeHub server
|
||||
*
|
||||
* These tests create real identity memories, verify CRUD operations, then clean up.
|
||||
*/
|
||||
|
||||
const CLI = process.env.LH_CLI_PATH || 'lh';
|
||||
const TIMEOUT = 60_000;
|
||||
|
||||
function run(args: string): string {
|
||||
return execSync(`${CLI} ${args}`, {
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, PATH: `${process.env.HOME}/.bun/bin:${process.env.PATH}` },
|
||||
timeout: TIMEOUT,
|
||||
}).trim();
|
||||
}
|
||||
|
||||
function runJson<T = any>(args: string): T {
|
||||
const output = run(args);
|
||||
return JSON.parse(output) as T;
|
||||
}
|
||||
|
||||
describe(
|
||||
'lh memory - E2E',
|
||||
() => {
|
||||
const testDesc = `E2E-Memory-${Date.now()}`;
|
||||
let createdIdentityId: string;
|
||||
|
||||
// ── create ────────────────────────────────────────────
|
||||
|
||||
describe('create', () => {
|
||||
it('should create an identity memory with all options', () => {
|
||||
const output = run(
|
||||
`memory create --type personal --role developer --relationship self -d "${testDesc}" --labels e2e test`,
|
||||
);
|
||||
expect(output).toContain('Created identity memory');
|
||||
|
||||
// Extract both IDs: "Created identity memory mem_xxx (identity: mem_yyy)"
|
||||
const memMatch = output.match(/memory\s+(mem_\w+)/);
|
||||
const idMatch = output.match(/identity:\s+(mem_\w+)/);
|
||||
expect(memMatch).not.toBeNull();
|
||||
expect(idMatch).not.toBeNull();
|
||||
createdIdentityId = idMatch![1];
|
||||
});
|
||||
|
||||
it('should appear in the identity list', () => {
|
||||
const list = runJson<any[]>('memory list identity --json id,description');
|
||||
const found = list.find((m) => m.id === createdIdentityId);
|
||||
expect(found).toBeDefined();
|
||||
expect(found.description).toBe(testDesc);
|
||||
});
|
||||
});
|
||||
|
||||
// ── list ──────────────────────────────────────────────
|
||||
|
||||
describe('list', () => {
|
||||
it('should list all memory categories without error', () => {
|
||||
expect(() => run('memory list')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should list a specific category in table format', () => {
|
||||
const output = run('memory list identity');
|
||||
expect(output).toContain('Identity');
|
||||
expect(output).toContain('ID');
|
||||
});
|
||||
|
||||
it('should output JSON for all categories', () => {
|
||||
const result = runJson<Record<string, any[]>>('memory list --json');
|
||||
expect(typeof result).toBe('object');
|
||||
expect(result).toHaveProperty('identity');
|
||||
expect(result).toHaveProperty('activity');
|
||||
expect(result).toHaveProperty('context');
|
||||
expect(result).toHaveProperty('experience');
|
||||
expect(result).toHaveProperty('preference');
|
||||
});
|
||||
|
||||
it('should output JSON array for specific category', () => {
|
||||
const result = runJson<any[]>('memory list identity --json');
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should support JSON field filtering', () => {
|
||||
const result = runJson<any[]>('memory list identity --json id,description');
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
if (result.length > 0) {
|
||||
expect(result[0]).toHaveProperty('id');
|
||||
expect(result[0]).toHaveProperty('description');
|
||||
}
|
||||
});
|
||||
|
||||
it('should error for invalid category', () => {
|
||||
expect(() => run('memory list invalidcategory')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── edit ──────────────────────────────────────────────
|
||||
|
||||
describe('edit', () => {
|
||||
const updatedDesc = `${testDesc}-Updated`;
|
||||
|
||||
it('should update identity memory description', () => {
|
||||
const output = run(`memory edit identity ${createdIdentityId} -d "${updatedDesc}"`);
|
||||
expect(output).toContain('Updated identity memory');
|
||||
expect(output).toContain(createdIdentityId);
|
||||
});
|
||||
|
||||
it('should reflect the update in list', () => {
|
||||
const list = runJson<any[]>('memory list identity --json id,description');
|
||||
const found = list.find((m) => m.id === createdIdentityId);
|
||||
expect(found).toBeDefined();
|
||||
expect(found.description).toBe(updatedDesc);
|
||||
});
|
||||
|
||||
it('should error on invalid category', () => {
|
||||
expect(() => run(`memory edit invalidcat ${createdIdentityId} -d "test"`)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── persona ───────────────────────────────────────────
|
||||
|
||||
describe('persona', () => {
|
||||
it('should show persona summary or empty message', () => {
|
||||
const output = run('memory persona');
|
||||
expect(output).toBeTruthy();
|
||||
expect(output.includes('User Persona') || output.includes('No persona data')).toBe(true);
|
||||
});
|
||||
|
||||
it('should output JSON with --json flag', () => {
|
||||
const output = run('memory persona --json');
|
||||
expect(() => JSON.parse(output)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── extract & extract-status ────────────────────────────
|
||||
// NOTE: `memory extract` requires backend extraction service which returns 500
|
||||
// in dev environments. These commands are tested only in production E2E runs.
|
||||
// `memory extract-status` is a read-only check that works without triggering extraction.
|
||||
|
||||
describe('extract-status', () => {
|
||||
it('should check extraction task status without error', () => {
|
||||
// extract-status is read-only; it returns latest task or empty
|
||||
expect(() => run('memory extract-status')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── delete (cleanup) ──────────────────────────────────
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete the identity memory', () => {
|
||||
const output = run(`memory delete identity ${createdIdentityId} --yes`);
|
||||
expect(output).toContain('Deleted identity memory');
|
||||
expect(output).toContain(createdIdentityId);
|
||||
});
|
||||
|
||||
it('should no longer appear in the list', () => {
|
||||
const list = runJson<any[]>('memory list identity --json id');
|
||||
const found = list.find((m) => m.id === createdIdentityId);
|
||||
expect(found).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should error on invalid category', () => {
|
||||
expect(() => run('memory delete invalidcat some_id --yes')).toThrow();
|
||||
});
|
||||
});
|
||||
},
|
||||
{ timeout: TIMEOUT },
|
||||
);
|
||||
@@ -0,0 +1,98 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* E2E tests for `lh message` message management commands.
|
||||
*
|
||||
* Prerequisites:
|
||||
* - `lh` CLI is installed and linked globally
|
||||
* - User is authenticated (`lh login` completed)
|
||||
* - Network access to the LobeHub server
|
||||
*/
|
||||
|
||||
const CLI = process.env.LH_CLI_PATH || 'lh';
|
||||
const TIMEOUT = 30_000;
|
||||
|
||||
function run(args: string): string {
|
||||
return execSync(`${CLI} ${args}`, {
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, PATH: `${process.env.HOME}/.bun/bin:${process.env.PATH}` },
|
||||
timeout: TIMEOUT,
|
||||
}).trim();
|
||||
}
|
||||
|
||||
function runJson<T = any>(args: string): T {
|
||||
const output = run(args);
|
||||
return JSON.parse(output) as T;
|
||||
}
|
||||
|
||||
describe('lh message - E2E', () => {
|
||||
// ── list ──────────────────────────────────────────────
|
||||
|
||||
describe('list', () => {
|
||||
it('should list messages in table format', () => {
|
||||
const output = run('message list');
|
||||
// Either shows table or "No messages found."
|
||||
expect(output).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should output JSON', () => {
|
||||
const list = runJson<any[]>('message list --json id,role');
|
||||
expect(Array.isArray(list)).toBe(true);
|
||||
if (list.length > 0) {
|
||||
expect(list[0]).toHaveProperty('id');
|
||||
expect(list[0]).toHaveProperty('role');
|
||||
}
|
||||
});
|
||||
|
||||
it('should respect limit option', () => {
|
||||
const list = runJson<any[]>('message list --json id -L 5');
|
||||
expect(list.length).toBeLessThanOrEqual(5);
|
||||
});
|
||||
});
|
||||
|
||||
// ── search ────────────────────────────────────────────
|
||||
|
||||
describe('search', () => {
|
||||
it('should search messages', () => {
|
||||
const output = run('message search "hello"');
|
||||
expect(typeof output).toBe('string');
|
||||
});
|
||||
|
||||
it('should output JSON', () => {
|
||||
const list = runJson<any[]>('message search "hello" --json id,role');
|
||||
expect(Array.isArray(list)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── count ─────────────────────────────────────────────
|
||||
|
||||
describe('count', () => {
|
||||
it('should show message count', () => {
|
||||
const output = run('message count');
|
||||
expect(output).toContain('Messages:');
|
||||
});
|
||||
|
||||
it('should output JSON', () => {
|
||||
const output = run('message count --json');
|
||||
const parsed = JSON.parse(output);
|
||||
expect(parsed).toHaveProperty('count');
|
||||
expect(typeof parsed.count).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
// ── heatmap ───────────────────────────────────────────
|
||||
|
||||
describe('heatmap', () => {
|
||||
it('should show heatmap data', () => {
|
||||
const output = run('message heatmap');
|
||||
expect(output).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should accept --json flag without error', () => {
|
||||
// Heatmap JSON can be very large; just verify the command doesn't throw
|
||||
expect(() => run('message heatmap --json')).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* E2E tests for `lh model` AI model management commands.
|
||||
*
|
||||
* Prerequisites:
|
||||
* - `lh` CLI is installed and linked globally
|
||||
* - User is authenticated (`lh login` completed)
|
||||
* - Network access to the LobeHub server
|
||||
* - At least one provider (e.g. openai) must be available
|
||||
*
|
||||
* These tests create a real model, verify CRUD operations, then clean up.
|
||||
*/
|
||||
|
||||
const CLI = process.env.LH_CLI_PATH || 'lh';
|
||||
const TIMEOUT = 30_000;
|
||||
const TEST_PROVIDER = 'openai';
|
||||
|
||||
function run(args: string): string {
|
||||
return execSync(`${CLI} ${args}`, {
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, PATH: `${process.env.HOME}/.bun/bin:${process.env.PATH}` },
|
||||
timeout: TIMEOUT,
|
||||
}).trim();
|
||||
}
|
||||
|
||||
function runJson<T = any>(args: string): T {
|
||||
const output = run(args);
|
||||
return JSON.parse(output) as T;
|
||||
}
|
||||
|
||||
describe('lh model - E2E', () => {
|
||||
const testModelId = `e2e-model-${Date.now()}`;
|
||||
const testDisplayName = 'E2E Test Model';
|
||||
|
||||
// ── list ──────────────────────────────────────────────
|
||||
|
||||
describe('list', () => {
|
||||
it('should list models for a provider in table format', () => {
|
||||
const output = run(`model list ${TEST_PROVIDER}`);
|
||||
expect(output).toContain('ID');
|
||||
expect(output).toContain('NAME');
|
||||
expect(output).toContain('ENABLED');
|
||||
expect(output).toContain('TYPE');
|
||||
});
|
||||
|
||||
it('should filter enabled models', () => {
|
||||
const output = run(`model list ${TEST_PROVIDER} --enabled`);
|
||||
// Every row should have ✓
|
||||
expect(output).not.toContain('✗');
|
||||
});
|
||||
|
||||
it('should output JSON with field filtering', () => {
|
||||
const list = runJson<{ id: string; type: string }[]>(
|
||||
`model list ${TEST_PROVIDER} --json id,type -L 5`,
|
||||
);
|
||||
expect(Array.isArray(list)).toBe(true);
|
||||
expect(list.length).toBeLessThanOrEqual(5);
|
||||
if (list.length > 0) {
|
||||
expect(list[0]).toHaveProperty('id');
|
||||
expect(list[0]).toHaveProperty('type');
|
||||
expect(list[0]).not.toHaveProperty('displayName');
|
||||
}
|
||||
});
|
||||
|
||||
it('should respect limit option', () => {
|
||||
const list = runJson<any[]>(`model list ${TEST_PROVIDER} --json id -L 3`);
|
||||
expect(list.length).toBeLessThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
// ── create ────────────────────────────────────────────
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a new model', () => {
|
||||
const output = run(
|
||||
`model create --id ${testModelId} --provider ${TEST_PROVIDER} --display-name "${testDisplayName}" --type chat`,
|
||||
);
|
||||
expect(output).toContain('Created model');
|
||||
});
|
||||
|
||||
it('should appear in the model list', () => {
|
||||
const list = runJson<{ id: string }[]>(`model list ${TEST_PROVIDER} --json id`);
|
||||
const found = list.find((m) => m.id === testModelId);
|
||||
expect(found).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── view ──────────────────────────────────────────────
|
||||
|
||||
describe('view', () => {
|
||||
it('should view model details', () => {
|
||||
const output = run(`model view ${testModelId}`);
|
||||
expect(output).toContain(testDisplayName);
|
||||
expect(output).toContain(TEST_PROVIDER);
|
||||
expect(output).toContain('chat');
|
||||
});
|
||||
|
||||
it('should output JSON', () => {
|
||||
const result = runJson<{
|
||||
displayName: string;
|
||||
id: string;
|
||||
providerId: string;
|
||||
type: string;
|
||||
}>(`model view ${testModelId} --json id,displayName,providerId,type`);
|
||||
expect(result.id).toBe(testModelId);
|
||||
expect(result.displayName).toBe(testDisplayName);
|
||||
expect(result.providerId).toBe(TEST_PROVIDER);
|
||||
expect(result.type).toBe('chat');
|
||||
});
|
||||
|
||||
it('should error for nonexistent model', () => {
|
||||
expect(() => run('model view nonexistent-model-xyz')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── edit ──────────────────────────────────────────────
|
||||
|
||||
describe('edit', () => {
|
||||
const updatedName = `${testDisplayName}-Updated`;
|
||||
|
||||
it('should update model display name', () => {
|
||||
const output = run(
|
||||
`model edit ${testModelId} --provider ${TEST_PROVIDER} --display-name "${updatedName}"`,
|
||||
);
|
||||
expect(output).toContain('Updated model');
|
||||
});
|
||||
|
||||
it('should reflect updates when viewed', () => {
|
||||
const result = runJson<{ displayName: string }>(
|
||||
`model view ${testModelId} --json displayName`,
|
||||
);
|
||||
expect(result.displayName).toBe(updatedName);
|
||||
});
|
||||
|
||||
it('should error when no changes specified', () => {
|
||||
expect(() => run(`model edit ${testModelId} --provider ${TEST_PROVIDER}`)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── toggle ────────────────────────────────────────────
|
||||
|
||||
describe('toggle', () => {
|
||||
it('should disable model', () => {
|
||||
const output = run(`model toggle ${testModelId} --provider ${TEST_PROVIDER} --disable`);
|
||||
expect(output).toContain('disabled');
|
||||
});
|
||||
|
||||
it('should reflect disabled status', () => {
|
||||
const result = runJson<{ enabled: boolean }>(`model view ${testModelId} --json enabled`);
|
||||
expect(result.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should enable model', () => {
|
||||
const output = run(`model toggle ${testModelId} --provider ${TEST_PROVIDER} --enable`);
|
||||
expect(output).toContain('enabled');
|
||||
});
|
||||
|
||||
it('should error when no flag specified', () => {
|
||||
expect(() => run(`model toggle ${testModelId} --provider ${TEST_PROVIDER}`)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── batch-toggle ──────────────────────────────────────
|
||||
|
||||
describe('batch-toggle', () => {
|
||||
it('should batch disable models', () => {
|
||||
const output = run(`model batch-toggle ${testModelId} --provider ${TEST_PROVIDER} --disable`);
|
||||
expect(output).toContain('Disabled');
|
||||
expect(output).toContain('1 model(s)');
|
||||
});
|
||||
|
||||
it('should batch enable models', () => {
|
||||
const output = run(`model batch-toggle ${testModelId} --provider ${TEST_PROVIDER} --enable`);
|
||||
expect(output).toContain('Enabled');
|
||||
expect(output).toContain('1 model(s)');
|
||||
});
|
||||
});
|
||||
|
||||
// ── delete (cleanup) ──────────────────────────────────
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete the model', () => {
|
||||
const output = run(`model delete ${testModelId} --provider ${TEST_PROVIDER} --yes`);
|
||||
expect(output).toContain('Deleted model');
|
||||
expect(output).toContain(testModelId);
|
||||
});
|
||||
|
||||
it('should no longer be viewable', () => {
|
||||
expect(() => run(`model view ${testModelId}`)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── clear (test with caution) ─────────────────────────
|
||||
|
||||
describe('clear', () => {
|
||||
it('should clear remote models for provider', () => {
|
||||
const output = run(`model clear --provider ${TEST_PROVIDER} --remote --yes`);
|
||||
expect(output).toContain('Cleared remote models');
|
||||
expect(output).toContain(TEST_PROVIDER);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* E2E tests for `lh plugin` plugin management commands.
|
||||
*
|
||||
* Prerequisites:
|
||||
* - `lh` CLI is installed and linked globally
|
||||
* - User is authenticated (`lh login` completed)
|
||||
* - Network access to the LobeHub server
|
||||
*/
|
||||
|
||||
const CLI = process.env.LH_CLI_PATH || 'lh';
|
||||
const TIMEOUT = 30_000;
|
||||
|
||||
function run(args: string): string {
|
||||
return execSync(`${CLI} ${args}`, {
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, PATH: `${process.env.HOME}/.bun/bin:${process.env.PATH}` },
|
||||
timeout: TIMEOUT,
|
||||
}).trim();
|
||||
}
|
||||
|
||||
function runJson<T = any>(args: string): T {
|
||||
const output = run(args);
|
||||
return JSON.parse(output) as T;
|
||||
}
|
||||
|
||||
describe('lh plugin - E2E', () => {
|
||||
// ── list ──────────────────────────────────────────────
|
||||
|
||||
describe('list', () => {
|
||||
it('should list plugins or show empty message', () => {
|
||||
const output = run('plugin list');
|
||||
expect(output).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should output JSON', () => {
|
||||
const list = runJson<any[]>('plugin list --json');
|
||||
expect(Array.isArray(list)).toBe(true);
|
||||
});
|
||||
|
||||
it('should output JSON with field filtering', () => {
|
||||
const list = runJson<any[]>('plugin list --json id,identifier');
|
||||
expect(Array.isArray(list)).toBe(true);
|
||||
if (list.length > 0) {
|
||||
expect(list[0]).toHaveProperty('id');
|
||||
expect(list[0]).toHaveProperty('identifier');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── install / update / uninstall ──────────────────────
|
||||
// Note: Full CRUD requires a valid manifest JSON which is complex.
|
||||
// We test error handling for invalid inputs instead.
|
||||
|
||||
describe('install', () => {
|
||||
it('should reject invalid manifest JSON', () => {
|
||||
expect(() => run('plugin install -i "test-plugin" --manifest "not-json"')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should error when no changes specified', () => {
|
||||
expect(() => run('plugin update nonexistent-id')).toThrow();
|
||||
});
|
||||
|
||||
it('should reject invalid settings JSON', () => {
|
||||
expect(() => run('plugin update some-id --settings "not-json"')).toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* E2E tests for `lh provider` AI provider management commands.
|
||||
*
|
||||
* Prerequisites:
|
||||
* - `lh` CLI is installed and linked globally
|
||||
* - User is authenticated (`lh login` completed)
|
||||
* - Network access to the LobeHub server
|
||||
*
|
||||
* These tests create a real provider, verify CRUD operations, then clean up.
|
||||
*/
|
||||
|
||||
const CLI = process.env.LH_CLI_PATH || 'lh';
|
||||
const TIMEOUT = 30_000;
|
||||
|
||||
function run(args: string): string {
|
||||
return execSync(`${CLI} ${args}`, {
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, PATH: `${process.env.HOME}/.bun/bin:${process.env.PATH}` },
|
||||
timeout: TIMEOUT,
|
||||
}).trim();
|
||||
}
|
||||
|
||||
function runJson<T = any>(args: string): T {
|
||||
const output = run(args);
|
||||
return JSON.parse(output) as T;
|
||||
}
|
||||
|
||||
describe('lh provider - E2E', () => {
|
||||
const testId = `e2e-test-${Date.now()}`;
|
||||
const testName = 'E2E Test Provider';
|
||||
|
||||
// ── list ──────────────────────────────────────────────
|
||||
|
||||
describe('list', () => {
|
||||
it('should list providers in table format', () => {
|
||||
const output = run('provider list');
|
||||
expect(output).toContain('ID');
|
||||
expect(output).toContain('NAME');
|
||||
expect(output).toContain('ENABLED');
|
||||
expect(output).toContain('SOURCE');
|
||||
});
|
||||
|
||||
it('should output JSON with field filtering', () => {
|
||||
const list = runJson<{ id: string; name: string }[]>('provider list --json id,name');
|
||||
expect(Array.isArray(list)).toBe(true);
|
||||
expect(list.length).toBeGreaterThan(0);
|
||||
const first = list[0];
|
||||
expect(first).toHaveProperty('id');
|
||||
expect(first).toHaveProperty('name');
|
||||
expect(first).not.toHaveProperty('description');
|
||||
});
|
||||
});
|
||||
|
||||
// ── view ──────────────────────────────────────────────
|
||||
|
||||
describe('view', () => {
|
||||
it('should view a builtin provider', () => {
|
||||
const output = run('provider view openai');
|
||||
// Should show name or id and status
|
||||
expect(output).toMatch(/Enabled|Disabled/);
|
||||
expect(output).toContain('builtin');
|
||||
});
|
||||
|
||||
it('should output JSON for a provider', () => {
|
||||
const result = runJson<{ id: string; source: string }>(
|
||||
'provider view openai --json id,source',
|
||||
);
|
||||
expect(result.id).toBe('openai');
|
||||
expect(result.source).toBe('builtin');
|
||||
});
|
||||
|
||||
it('should error for nonexistent provider', () => {
|
||||
expect(() => run('provider view nonexistent-provider-xyz')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── create ────────────────────────────────────────────
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a new provider', () => {
|
||||
const output = run(
|
||||
`provider create --id ${testId} -n "${testName}" -d "E2E test" --sdk-type openai`,
|
||||
);
|
||||
expect(output).toContain('Created provider');
|
||||
expect(output).toContain(testId);
|
||||
});
|
||||
|
||||
it('should appear in the list', () => {
|
||||
const list = runJson<{ id: string; name: string }[]>('provider list --json id,name');
|
||||
const found = list.find((p) => p.id === testId);
|
||||
expect(found).toBeDefined();
|
||||
expect(found!.name).toBe(testName);
|
||||
});
|
||||
});
|
||||
|
||||
// ── edit ──────────────────────────────────────────────
|
||||
|
||||
describe('edit', () => {
|
||||
const updatedName = `${testName}-Updated`;
|
||||
|
||||
it('should update provider name', () => {
|
||||
const output = run(`provider edit ${testId} -n "${updatedName}"`);
|
||||
expect(output).toContain('Updated provider');
|
||||
expect(output).toContain(testId);
|
||||
});
|
||||
|
||||
it('should reflect updates when viewed', () => {
|
||||
const result = runJson<{ name: string }>(`provider view ${testId} --json name`);
|
||||
expect(result.name).toBe(updatedName);
|
||||
});
|
||||
|
||||
it('should error when no changes specified', () => {
|
||||
expect(() => run(`provider edit ${testId}`)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── config ────────────────────────────────────────────
|
||||
|
||||
describe('config', () => {
|
||||
it('should set api key and base url', () => {
|
||||
const output = run(
|
||||
`provider config ${testId} --api-key sk-e2etest123456 --base-url https://api.e2e.test/v1`,
|
||||
);
|
||||
expect(output).toContain('Updated config');
|
||||
});
|
||||
|
||||
it('should set check model', () => {
|
||||
const output = run(`provider config ${testId} --check-model gpt-4o`);
|
||||
expect(output).toContain('Updated config');
|
||||
});
|
||||
|
||||
it('should enable response api', () => {
|
||||
const output = run(`provider config ${testId} --enable-response-api`);
|
||||
expect(output).toContain('Updated config');
|
||||
});
|
||||
|
||||
it('should show current config', () => {
|
||||
const output = run(`provider config ${testId} --show`);
|
||||
expect(output).toContain('Config for');
|
||||
expect(output).toContain('gpt-4o');
|
||||
expect(output).toContain('sk-e2ete');
|
||||
expect(output).toContain('https://api.e2e.test/v1');
|
||||
});
|
||||
|
||||
it('should show config as JSON', () => {
|
||||
const result = runJson<{
|
||||
checkModel: string;
|
||||
keyVaults: { apiKey: string; baseURL: string };
|
||||
}>(`provider config ${testId} --show --json`);
|
||||
expect(result.checkModel).toBe('gpt-4o');
|
||||
expect(result.keyVaults.apiKey).toContain('sk-e2etest');
|
||||
expect(result.keyVaults.baseURL).toBe('https://api.e2e.test/v1');
|
||||
});
|
||||
|
||||
it('should error when no config specified', () => {
|
||||
expect(() => run(`provider config ${testId}`)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── toggle ────────────────────────────────────────────
|
||||
|
||||
describe('toggle', () => {
|
||||
it('should disable provider', () => {
|
||||
const output = run(`provider toggle ${testId} --disable`);
|
||||
expect(output).toContain('disabled');
|
||||
});
|
||||
|
||||
it('should reflect disabled status', () => {
|
||||
const result = runJson<{ enabled: boolean }>(`provider view ${testId} --json enabled`);
|
||||
expect(result.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should enable provider', () => {
|
||||
const output = run(`provider toggle ${testId} --enable`);
|
||||
expect(output).toContain('enabled');
|
||||
});
|
||||
|
||||
it('should error when no flag specified', () => {
|
||||
expect(() => run(`provider toggle ${testId}`)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── test (connectivity) ───────────────────────────────
|
||||
|
||||
describe('test', () => {
|
||||
it('should check provider connectivity (expect fail with fake key)', () => {
|
||||
// The e2e test provider has a fake API key, so test should fail
|
||||
expect(() => run(`provider test ${testId}`)).toThrow();
|
||||
});
|
||||
|
||||
it('should output JSON on failure', () => {
|
||||
try {
|
||||
run(`provider test ${testId} --json`);
|
||||
} catch {
|
||||
// Command exits with code 1 but may still output JSON before that
|
||||
// This is expected behavior
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── delete (cleanup) ──────────────────────────────────
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete the provider', () => {
|
||||
const output = run(`provider delete ${testId} --yes`);
|
||||
expect(output).toContain('Deleted provider');
|
||||
expect(output).toContain(testId);
|
||||
});
|
||||
|
||||
it('should no longer appear in the list', () => {
|
||||
const list = runJson<{ id: string }[]>('provider list --json id');
|
||||
const found = list.find((p) => p.id === testId);
|
||||
expect(found).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* E2E tests for `lh search` global search command.
|
||||
*
|
||||
* Prerequisites:
|
||||
* - `lh` CLI is installed and linked globally
|
||||
* - User is authenticated (`lh login` completed)
|
||||
* - Network access to the LobeHub server
|
||||
*/
|
||||
|
||||
const CLI = process.env.LH_CLI_PATH || 'lh';
|
||||
const TIMEOUT = 30_000;
|
||||
|
||||
function run(args: string): string {
|
||||
return execSync(`${CLI} ${args}`, {
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, PATH: `${process.env.HOME}/.bun/bin:${process.env.PATH}` },
|
||||
timeout: TIMEOUT,
|
||||
}).trim();
|
||||
}
|
||||
|
||||
function runJson<T = any>(args: string): T {
|
||||
const output = run(args);
|
||||
return JSON.parse(output) as T;
|
||||
}
|
||||
|
||||
describe('lh search - E2E', () => {
|
||||
it('should search across types', () => {
|
||||
const output = run('search "test"');
|
||||
// May show results or "No results found."
|
||||
expect(output).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should output JSON', () => {
|
||||
const result = runJson('search "test" --json');
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should filter by type', () => {
|
||||
const output = run('search "test" --type agent');
|
||||
expect(output).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should respect limit option', () => {
|
||||
const result = runJson('search "test" --json -L 3');
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should error for invalid type', () => {
|
||||
expect(() => run('search "test" --type invalidtype')).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* E2E tests for `lh skill` agent skill management commands.
|
||||
*
|
||||
* Prerequisites:
|
||||
* - `lh` CLI is installed and linked globally
|
||||
* - User is authenticated (`lh login` completed)
|
||||
* - Network access to the LobeHub server
|
||||
*
|
||||
* These tests create a real skill, verify CRUD operations, then clean up.
|
||||
*/
|
||||
|
||||
const CLI = process.env.LH_CLI_PATH || 'lh';
|
||||
const TIMEOUT = 30_000;
|
||||
|
||||
function run(args: string): string {
|
||||
return execSync(`${CLI} ${args}`, {
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, PATH: `${process.env.HOME}/.bun/bin:${process.env.PATH}` },
|
||||
timeout: TIMEOUT,
|
||||
}).trim();
|
||||
}
|
||||
|
||||
function runJson<T = any>(args: string): T {
|
||||
const output = run(args);
|
||||
return JSON.parse(output) as T;
|
||||
}
|
||||
|
||||
describe('lh skill - E2E', () => {
|
||||
const testName = `E2E-Skill-${Date.now()}`;
|
||||
const testDescription = 'Created by E2E test';
|
||||
const testContent = 'You are a helpful test skill.';
|
||||
const testIdentifier = `e2e-test-skill-${Date.now()}`;
|
||||
let createdId: string;
|
||||
|
||||
// ── create ────────────────────────────────────────────
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a skill and return its id', () => {
|
||||
const output = run(
|
||||
`skill create -n "${testName}" -d "${testDescription}" -c "${testContent}" -i "${testIdentifier}"`,
|
||||
);
|
||||
expect(output).toContain('Created skill');
|
||||
|
||||
// Extract id from output like "✓ Created skill xxx"
|
||||
const match = output.match(/Created skill\s+(\S+)/);
|
||||
expect(match).not.toBeNull();
|
||||
createdId = match![1];
|
||||
});
|
||||
|
||||
it('should be viewable after creation', () => {
|
||||
const result = runJson<{ id: string; name: string }>(
|
||||
`skill view ${createdId} --json id,name`,
|
||||
);
|
||||
expect(result.id).toBe(createdId);
|
||||
expect(result.name).toBe(testName);
|
||||
});
|
||||
});
|
||||
|
||||
// ── list ──────────────────────────────────────────────
|
||||
|
||||
describe('list', () => {
|
||||
it('should return valid output (table or empty message)', () => {
|
||||
const output = run('skill list');
|
||||
// May return table or "No skills found." depending on backend state
|
||||
expect(output).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should output JSON array', () => {
|
||||
const list = runJson<any[]>('skill list --json id,name');
|
||||
expect(Array.isArray(list)).toBe(true);
|
||||
if (list.length > 0) {
|
||||
expect(list[0]).toHaveProperty('id');
|
||||
expect(list[0]).toHaveProperty('name');
|
||||
expect(list[0]).not.toHaveProperty('content');
|
||||
}
|
||||
});
|
||||
|
||||
it('should filter by source', () => {
|
||||
const list = runJson<{ id: string; source: string }[]>(
|
||||
'skill list --source user --json id,source',
|
||||
);
|
||||
expect(Array.isArray(list)).toBe(true);
|
||||
for (const item of list) {
|
||||
expect(item.source).toBe('user');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── view ──────────────────────────────────────────────
|
||||
|
||||
describe('view', () => {
|
||||
it('should view skill details', () => {
|
||||
const output = run(`skill view ${createdId}`);
|
||||
expect(output).toContain(testName);
|
||||
expect(output).toContain(testDescription);
|
||||
});
|
||||
|
||||
it('should output JSON with --json flag', () => {
|
||||
const result = runJson<{
|
||||
description: string;
|
||||
id: string;
|
||||
name: string;
|
||||
}>(`skill view ${createdId} --json id,name,description`);
|
||||
expect(result.id).toBe(createdId);
|
||||
expect(result.name).toBe(testName);
|
||||
expect(result.description).toBe(testDescription);
|
||||
});
|
||||
});
|
||||
|
||||
// ── edit ──────────────────────────────────────────────
|
||||
|
||||
describe('edit', () => {
|
||||
const updatedName = `${testName}-Updated`;
|
||||
const updatedDesc = 'Updated by E2E test';
|
||||
const updatedContent = 'Updated content for test skill.';
|
||||
|
||||
it('should update skill name and description', () => {
|
||||
const output = run(`skill edit ${createdId} -n "${updatedName}" -d "${updatedDesc}"`);
|
||||
expect(output).toContain('Updated skill');
|
||||
expect(output).toContain(createdId);
|
||||
});
|
||||
|
||||
it('should reflect name/description updates when viewed', () => {
|
||||
const result = runJson<{ description: string; name: string }>(
|
||||
`skill view ${createdId} --json name,description`,
|
||||
);
|
||||
expect(result.name).toBe(updatedName);
|
||||
expect(result.description).toBe(updatedDesc);
|
||||
});
|
||||
|
||||
it('should update skill content', () => {
|
||||
const output = run(`skill edit ${createdId} -c "${updatedContent}"`);
|
||||
expect(output).toContain('Updated skill');
|
||||
expect(output).toContain(createdId);
|
||||
});
|
||||
|
||||
it('should reflect content update when viewed', () => {
|
||||
const result = runJson<{ content: string }>(`skill view ${createdId} --json content`);
|
||||
expect(result.content).toBe(updatedContent);
|
||||
});
|
||||
|
||||
it('should error when no changes specified', () => {
|
||||
expect(() => run(`skill edit ${createdId}`)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── search ────────────────────────────────────────────
|
||||
|
||||
describe('search', () => {
|
||||
it('should search skills in table format', () => {
|
||||
const output = run(`skill search "${testName}"`);
|
||||
// May or may not find results depending on indexing, but should not throw
|
||||
expect(typeof output).toBe('string');
|
||||
});
|
||||
|
||||
it('should output JSON with --json flag', () => {
|
||||
const list = runJson<any[]>(`skill search "${testName}" --json id,name`);
|
||||
expect(Array.isArray(list)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── delete ────────────────────────────────────────────
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete the skill', () => {
|
||||
const output = run(`skill delete ${createdId} --yes`);
|
||||
expect(output).toContain('Deleted skill');
|
||||
expect(output).toContain(createdId);
|
||||
});
|
||||
|
||||
it('should no longer appear in the list', () => {
|
||||
const list = runJson<{ id: string }[]>('skill list --source user --json id');
|
||||
const found = list.find((s) => s.id === createdId);
|
||||
expect(found).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* E2E tests for `lh topic` conversation topic management commands.
|
||||
*
|
||||
* Prerequisites:
|
||||
* - `lh` CLI is installed and linked globally
|
||||
* - User is authenticated (`lh login` completed)
|
||||
* - Network access to the LobeHub server
|
||||
*
|
||||
* These tests create a real topic, verify CRUD operations, then clean up.
|
||||
*/
|
||||
|
||||
const CLI = process.env.LH_CLI_PATH || 'lh';
|
||||
const TIMEOUT = 30_000;
|
||||
|
||||
function run(args: string): string {
|
||||
return execSync(`${CLI} ${args}`, {
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, PATH: `${process.env.HOME}/.bun/bin:${process.env.PATH}` },
|
||||
timeout: TIMEOUT,
|
||||
}).trim();
|
||||
}
|
||||
|
||||
function runJson<T = any>(args: string): T {
|
||||
const output = run(args);
|
||||
return JSON.parse(output) as T;
|
||||
}
|
||||
|
||||
describe('lh topic - E2E', () => {
|
||||
const testTitle = `E2E-Topic-${Date.now()}`;
|
||||
let createdId: string;
|
||||
|
||||
// ── create ────────────────────────────────────────────
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a topic', () => {
|
||||
const output = run(`topic create -t "${testTitle}"`);
|
||||
expect(output).toContain('Created topic');
|
||||
|
||||
const match = output.match(/Created topic\s+(\S+)/);
|
||||
expect(match).not.toBeNull();
|
||||
createdId = match![1];
|
||||
});
|
||||
});
|
||||
|
||||
// ── list ──────────────────────────────────────────────
|
||||
|
||||
describe('list', () => {
|
||||
it('should list topics in table format', () => {
|
||||
const output = run('topic list');
|
||||
// Should show table headers or "No topics"
|
||||
expect(output).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should output JSON', () => {
|
||||
const list = runJson<any[]>('topic list --json id,title');
|
||||
expect(Array.isArray(list)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── search ────────────────────────────────────────────
|
||||
|
||||
describe('search', () => {
|
||||
it('should search topics', () => {
|
||||
const output = run(`topic search "${testTitle}"`);
|
||||
expect(typeof output).toBe('string');
|
||||
});
|
||||
|
||||
it('should output JSON', () => {
|
||||
const list = runJson<any[]>(`topic search "${testTitle}" --json id,title`);
|
||||
expect(Array.isArray(list)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── edit ──────────────────────────────────────────────
|
||||
|
||||
describe('edit', () => {
|
||||
const updatedTitle = `${testTitle}-Updated`;
|
||||
|
||||
it('should update topic title', () => {
|
||||
const output = run(`topic edit ${createdId} -t "${updatedTitle}"`);
|
||||
expect(output).toContain('Updated topic');
|
||||
});
|
||||
|
||||
it('should error when no changes specified', () => {
|
||||
expect(() => run(`topic edit ${createdId}`)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── recent ────────────────────────────────────────────
|
||||
|
||||
describe('recent', () => {
|
||||
it('should list recent topics', () => {
|
||||
const output = run('topic recent');
|
||||
expect(output).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should output JSON', () => {
|
||||
const list = runJson<any[]>('topic recent --json id,title');
|
||||
expect(Array.isArray(list)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── delete (cleanup) ──────────────────────────────────
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete the topic', () => {
|
||||
const output = run(`topic delete ${createdId} --yes`);
|
||||
expect(output).toContain('Deleted');
|
||||
expect(output).toContain('1 topic(s)');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,11 @@
|
||||
{
|
||||
"name": "@lobehub/cli",
|
||||
"version": "0.0.1-canary.5",
|
||||
"version": "0.0.1-canary.12",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"lh": "./dist/index.js"
|
||||
"lh": "./dist/index.js",
|
||||
"lobe": "./dist/index.js",
|
||||
"lobehub": "./dist/index.js"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
@@ -12,7 +14,7 @@
|
||||
"build": "npx tsup",
|
||||
"cli:link": "bun link",
|
||||
"cli:unlink": "bun unlink",
|
||||
"dev": "bun src/index.ts",
|
||||
"dev": "LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts",
|
||||
"prepublishOnly": "npm run build",
|
||||
"test": "bunx vitest run --config vitest.config.mts --silent='passed-only'",
|
||||
"test:coverage": "bunx vitest run --config vitest.config.mts --coverage",
|
||||
@@ -21,7 +23,8 @@
|
||||
"dependencies": {
|
||||
"@trpc/client": "^11.8.1",
|
||||
"commander": "^13.1.0",
|
||||
"diff": "^7.0.0",
|
||||
"debug": "^4.4.0",
|
||||
"diff": "^8.0.3",
|
||||
"fast-glob": "^3.3.3",
|
||||
"picocolors": "^1.1.1",
|
||||
"superjson": "^2.2.6",
|
||||
@@ -29,7 +32,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lobechat/device-gateway-client": "workspace:*",
|
||||
"@types/diff": "^6.0.0",
|
||||
"@lobechat/local-file-shell": "workspace:*",
|
||||
"@types/node": "^22.13.5",
|
||||
"@types/ws": "^8.18.1",
|
||||
"tsup": "^8.4.0",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
packages:
|
||||
- '../../packages/device-gateway-client'
|
||||
- '../../packages/local-file-shell'
|
||||
- '../../packages/file-loaders'
|
||||
- '.'
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createTRPCClient, httpLink } from '@trpc/client';
|
||||
import superjson from 'superjson';
|
||||
|
||||
import type { LambdaRouter } from '@/server/routers/lambda';
|
||||
import type { ToolsRouter } from '@/server/routers/tools';
|
||||
|
||||
import { getValidToken } from '../auth/refresh';
|
||||
import { OFFICIAL_SERVER_URL } from '../constants/urls';
|
||||
@@ -9,11 +10,18 @@ import { loadSettings } from '../settings';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
export type TrpcClient = ReturnType<typeof createTRPCClient<LambdaRouter>>;
|
||||
export type ToolsTrpcClient = ReturnType<typeof createTRPCClient<ToolsRouter>>;
|
||||
|
||||
let _client: TrpcClient | undefined;
|
||||
let _toolsClient: ToolsTrpcClient | undefined;
|
||||
|
||||
export async function getTrpcClient(): Promise<TrpcClient> {
|
||||
if (_client) return _client;
|
||||
async function getAuthAndServer() {
|
||||
// LOBEHUB_JWT + LOBEHUB_SERVER env vars (used by server-side sandbox execution)
|
||||
const envJwt = process.env.LOBEHUB_JWT;
|
||||
if (envJwt) {
|
||||
const serverUrl = process.env.LOBEHUB_SERVER || OFFICIAL_SERVER_URL;
|
||||
return { accessToken: envJwt, serverUrl: serverUrl.replace(/\/$/, '') };
|
||||
}
|
||||
|
||||
const result = await getValidToken();
|
||||
if (!result) {
|
||||
@@ -24,17 +32,41 @@ export async function getTrpcClient(): Promise<TrpcClient> {
|
||||
const accessToken = result.credentials.accessToken;
|
||||
const serverUrl = loadSettings()?.serverUrl || OFFICIAL_SERVER_URL;
|
||||
|
||||
return { accessToken, serverUrl: serverUrl.replace(/\/$/, '') };
|
||||
}
|
||||
|
||||
export async function getTrpcClient(): Promise<TrpcClient> {
|
||||
if (_client) return _client;
|
||||
|
||||
const { accessToken, serverUrl } = await getAuthAndServer();
|
||||
|
||||
_client = createTRPCClient<LambdaRouter>({
|
||||
links: [
|
||||
httpLink({
|
||||
headers: {
|
||||
'Oidc-Auth': accessToken,
|
||||
},
|
||||
headers: { 'Oidc-Auth': accessToken },
|
||||
transformer: superjson,
|
||||
url: `${serverUrl.replace(/\/$/, '')}/trpc/lambda`,
|
||||
url: `${serverUrl}/trpc/lambda`,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
return _client;
|
||||
}
|
||||
|
||||
export async function getToolsTrpcClient(): Promise<ToolsTrpcClient> {
|
||||
if (_toolsClient) return _toolsClient;
|
||||
|
||||
const { accessToken, serverUrl } = await getAuthAndServer();
|
||||
|
||||
_toolsClient = createTRPCClient<ToolsRouter>({
|
||||
links: [
|
||||
httpLink({
|
||||
headers: { 'Oidc-Auth': accessToken },
|
||||
transformer: superjson,
|
||||
url: `${serverUrl}/trpc/tools`,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
return _toolsClient;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ export interface StoredCredentials {
|
||||
refreshToken?: string;
|
||||
}
|
||||
|
||||
const CREDENTIALS_DIR = path.join(os.homedir(), '.lobehub');
|
||||
const LOBEHUB_DIR_NAME = process.env.LOBEHUB_CLI_HOME || '.lobehub';
|
||||
const CREDENTIALS_DIR = path.join(os.homedir(), LOBEHUB_DIR_NAME);
|
||||
const CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, 'credentials.json');
|
||||
|
||||
// Derive an encryption key from machine-specific info
|
||||
|
||||
@@ -29,6 +29,18 @@ function parseJwtSub(token: string): string | undefined {
|
||||
* Exits the process if no token can be resolved.
|
||||
*/
|
||||
export async function resolveToken(options: ResolveTokenOptions): Promise<ResolvedAuth> {
|
||||
// LOBEHUB_JWT env var takes highest priority (used by server-side sandbox execution)
|
||||
const envJwt = process.env.LOBEHUB_JWT;
|
||||
if (envJwt) {
|
||||
const userId = parseJwtSub(envJwt);
|
||||
if (!userId) {
|
||||
log.error('Could not extract userId from LOBEHUB_JWT.');
|
||||
process.exit(1);
|
||||
}
|
||||
log.debug('Using LOBEHUB_JWT from environment');
|
||||
return { token: envJwt, userId };
|
||||
}
|
||||
|
||||
// Explicit token takes priority
|
||||
if (options.token) {
|
||||
const userId = parseJwtSub(options.token);
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { registerAgentGroupCommand } from './agent-group';
|
||||
|
||||
const { mockTrpcClient } = vi.hoisted(() => ({
|
||||
mockTrpcClient: {
|
||||
group: {
|
||||
addAgentsToGroup: { mutate: vi.fn() },
|
||||
createGroup: { mutate: vi.fn() },
|
||||
deleteGroup: { mutate: vi.fn() },
|
||||
duplicateGroup: { mutate: vi.fn() },
|
||||
getGroupDetail: { query: vi.fn() },
|
||||
getGroups: { query: vi.fn() },
|
||||
removeAgentsFromGroup: { mutate: vi.fn() },
|
||||
updateGroup: { mutate: vi.fn() },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const { getTrpcClient: mockGetTrpcClient } = vi.hoisted(() => ({
|
||||
getTrpcClient: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../api/client', () => ({ getTrpcClient: mockGetTrpcClient }));
|
||||
vi.mock('../utils/logger', () => ({
|
||||
log: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() },
|
||||
setVerbose: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('agent-group command', () => {
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
|
||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
mockGetTrpcClient.mockResolvedValue(mockTrpcClient);
|
||||
for (const method of Object.values(mockTrpcClient.group)) {
|
||||
for (const fn of Object.values(method)) {
|
||||
(fn as ReturnType<typeof vi.fn>).mockReset();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
exitSpy.mockRestore();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
function createProgram() {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerAgentGroupCommand(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
describe('list', () => {
|
||||
it('should list agent groups', async () => {
|
||||
mockTrpcClient.group.getGroups.query.mockResolvedValue([
|
||||
{ agents: [{ id: 'a1' }], id: 'g1', title: 'Group 1' },
|
||||
]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'agent-group', 'list']);
|
||||
|
||||
expect(mockTrpcClient.group.getGroups.query).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should show empty message when no groups', async () => {
|
||||
mockTrpcClient.group.getGroups.query.mockResolvedValue([]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'agent-group', 'list']);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith('No agent groups found.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('view', () => {
|
||||
it('should view group details', async () => {
|
||||
mockTrpcClient.group.getGroupDetail.query.mockResolvedValue({
|
||||
agents: [{ id: 'a1', title: 'Agent 1' }],
|
||||
id: 'g1',
|
||||
title: 'Group 1',
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'agent-group', 'view', 'g1']);
|
||||
|
||||
expect(mockTrpcClient.group.getGroupDetail.query).toHaveBeenCalledWith({ id: 'g1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a group', async () => {
|
||||
mockTrpcClient.group.createGroup.mutate.mockResolvedValue({ group: { id: 'g1' } });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'agent-group', 'create', '-t', 'My Group']);
|
||||
|
||||
expect(mockTrpcClient.group.createGroup.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'My Group' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete a group', async () => {
|
||||
mockTrpcClient.group.deleteGroup.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'agent-group', 'delete', 'g1', '--yes']);
|
||||
|
||||
expect(mockTrpcClient.group.deleteGroup.mutate).toHaveBeenCalledWith({ id: 'g1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('duplicate', () => {
|
||||
it('should duplicate a group', async () => {
|
||||
mockTrpcClient.group.duplicateGroup.mutate.mockResolvedValue({ groupId: 'g2' });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'agent-group', 'duplicate', 'g1', '-t', 'Copy']);
|
||||
|
||||
expect(mockTrpcClient.group.duplicateGroup.mutate).toHaveBeenCalledWith({
|
||||
groupId: 'g1',
|
||||
newTitle: 'Copy',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('add-agents', () => {
|
||||
it('should add agents to group', async () => {
|
||||
mockTrpcClient.group.addAgentsToGroup.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent-group',
|
||||
'add-agents',
|
||||
'g1',
|
||||
'--agent-ids',
|
||||
'a1,a2',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.group.addAgentsToGroup.mutate).toHaveBeenCalledWith({
|
||||
agentIds: ['a1', 'a2'],
|
||||
groupId: 'g1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove-agents', () => {
|
||||
it('should remove agents from group', async () => {
|
||||
mockTrpcClient.group.removeAgentsFromGroup.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent-group',
|
||||
'remove-agents',
|
||||
'g1',
|
||||
'--agent-ids',
|
||||
'a1',
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.group.removeAgentsFromGroup.mutate).toHaveBeenCalledWith({
|
||||
agentIds: ['a1'],
|
||||
deleteVirtualAgents: true,
|
||||
groupId: 'g1',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../api/client';
|
||||
import { confirm, outputJson, printTable, truncate } from '../utils/format';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
export function registerAgentGroupCommand(program: Command) {
|
||||
const agentGroup = program.command('agent-group').description('Manage agent groups');
|
||||
|
||||
// ── list ──────────────────────────────────────────────
|
||||
|
||||
agentGroup
|
||||
.command('list')
|
||||
.description('List all agent groups')
|
||||
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
|
||||
.action(async (options: { json?: string | boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const groups = await client.group.getGroups.query();
|
||||
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(groups, fields);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!groups || (groups as any[]).length === 0) {
|
||||
console.log('No agent groups found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = (groups as any[]).map((g: any) => [
|
||||
g.id || '',
|
||||
truncate(g.title || 'Untitled', 40),
|
||||
String(g.agents?.length ?? 0),
|
||||
]);
|
||||
|
||||
printTable(rows, ['ID', 'TITLE', 'AGENTS']);
|
||||
});
|
||||
|
||||
// ── view ──────────────────────────────────────────────
|
||||
|
||||
agentGroup
|
||||
.command('view <id>')
|
||||
.description('View agent group details')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(async (id: string, options: { json?: string | boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const detail = await client.group.getGroupDetail.query({ id });
|
||||
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(detail, fields);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!detail) {
|
||||
log.error('Agent group not found.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const d = detail as any;
|
||||
console.log(`${pc.bold('ID:')} ${d.id}`);
|
||||
console.log(`${pc.bold('Title:')} ${d.title || 'Untitled'}`);
|
||||
if (d.description) console.log(`${pc.bold('Desc:')} ${d.description}`);
|
||||
|
||||
if (d.agents && d.agents.length > 0) {
|
||||
console.log(`\n${pc.bold('Agents:')}`);
|
||||
const rows = d.agents.map((a: any) => [
|
||||
a.id || '',
|
||||
truncate(a.title || 'Untitled', 30),
|
||||
a.role || '',
|
||||
a.enabled === false ? pc.dim('disabled') : pc.green('enabled'),
|
||||
]);
|
||||
printTable(rows, ['ID', 'TITLE', 'ROLE', 'STATUS']);
|
||||
}
|
||||
});
|
||||
|
||||
// ── create ────────────────────────────────────────────
|
||||
|
||||
agentGroup
|
||||
.command('create')
|
||||
.description('Create an agent group')
|
||||
.requiredOption('-t, --title <title>', 'Group title')
|
||||
.option('-d, --description <desc>', 'Group description')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(async (options: { description?: string; json?: boolean; title: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
const input: Record<string, any> = { title: options.title };
|
||||
if (options.description) input.description = options.description;
|
||||
|
||||
const result = await client.group.createGroup.mutate(input as any);
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const r = result as any;
|
||||
console.log(`${pc.green('✓')} Created agent group ${pc.bold(r.group?.id || '')}`);
|
||||
});
|
||||
|
||||
// ── edit ───────────────────────────────────────────────
|
||||
|
||||
agentGroup
|
||||
.command('edit <id>')
|
||||
.description('Update an agent group')
|
||||
.option('-t, --title <title>', 'Group title')
|
||||
.option('-d, --description <desc>', 'Group description')
|
||||
.action(async (id: string, options: { description?: string; title?: string }) => {
|
||||
const value: Record<string, any> = {};
|
||||
if (options.title) value.title = options.title;
|
||||
if (options.description) value.description = options.description;
|
||||
|
||||
if (Object.keys(value).length === 0) {
|
||||
log.error('No changes specified. Use --title or --description.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
await client.group.updateGroup.mutate({ id, value } as any);
|
||||
console.log(`${pc.green('✓')} Updated agent group ${pc.bold(id)}`);
|
||||
});
|
||||
|
||||
// ── delete ────────────────────────────────────────────
|
||||
|
||||
agentGroup
|
||||
.command('delete <id>')
|
||||
.description('Delete an agent group')
|
||||
.option('--yes', 'Skip confirmation prompt')
|
||||
.action(async (id: string, options: { yes?: boolean }) => {
|
||||
if (!options.yes) {
|
||||
const confirmed = await confirm('Are you sure you want to delete this agent group?');
|
||||
if (!confirmed) {
|
||||
console.log('Cancelled.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
await client.group.deleteGroup.mutate({ id });
|
||||
console.log(`${pc.green('✓')} Deleted agent group ${pc.bold(id)}`);
|
||||
});
|
||||
|
||||
// ── duplicate ─────────────────────────────────────────
|
||||
|
||||
agentGroup
|
||||
.command('duplicate <id>')
|
||||
.description('Duplicate an agent group')
|
||||
.option('-t, --title <title>', 'New title for the duplicated group')
|
||||
.action(async (id: string, options: { title?: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
const input: Record<string, any> = { groupId: id };
|
||||
if (options.title) input.newTitle = options.title;
|
||||
|
||||
const result = await client.group.duplicateGroup.mutate(input as any);
|
||||
const r = result as any;
|
||||
console.log(`${pc.green('✓')} Duplicated agent group → ${pc.bold(r.groupId || r.id || '')}`);
|
||||
});
|
||||
|
||||
// ── add-agents ────────────────────────────────────────
|
||||
|
||||
agentGroup
|
||||
.command('add-agents <groupId>')
|
||||
.description('Add agents to a group')
|
||||
.requiredOption('--agent-ids <ids>', 'Comma-separated agent IDs')
|
||||
.action(async (groupId: string, options: { agentIds: string }) => {
|
||||
const agentIds = options.agentIds.split(',').map((s) => s.trim());
|
||||
|
||||
const client = await getTrpcClient();
|
||||
await client.group.addAgentsToGroup.mutate({ agentIds, groupId });
|
||||
console.log(
|
||||
`${pc.green('✓')} Added ${agentIds.length} agent(s) to group ${pc.bold(groupId)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// ── remove-agents ─────────────────────────────────────
|
||||
|
||||
agentGroup
|
||||
.command('remove-agents <groupId>')
|
||||
.description('Remove agents from a group')
|
||||
.requiredOption('--agent-ids <ids>', 'Comma-separated agent IDs')
|
||||
.option('--keep-virtual', 'Keep virtual agents instead of deleting them')
|
||||
.option('--yes', 'Skip confirmation prompt')
|
||||
.action(
|
||||
async (
|
||||
groupId: string,
|
||||
options: { agentIds: string; keepVirtual?: boolean; yes?: boolean },
|
||||
) => {
|
||||
const agentIds = options.agentIds.split(',').map((s) => s.trim());
|
||||
|
||||
if (!options.yes) {
|
||||
const confirmed = await confirm(
|
||||
`Are you sure you want to remove ${agentIds.length} agent(s) from group?`,
|
||||
);
|
||||
if (!confirmed) {
|
||||
console.log('Cancelled.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
await client.group.removeAgentsFromGroup.mutate({
|
||||
agentIds,
|
||||
deleteVirtualAgents: !options.keepVirtual,
|
||||
groupId,
|
||||
});
|
||||
console.log(
|
||||
`${pc.green('✓')} Removed ${agentIds.length} agent(s) from group ${pc.bold(groupId)}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -8,11 +8,20 @@ const { mockTrpcClient } = vi.hoisted(() => ({
|
||||
mockTrpcClient: {
|
||||
agent: {
|
||||
createAgent: { mutate: vi.fn() },
|
||||
createAgentFiles: { mutate: vi.fn() },
|
||||
createAgentKnowledgeBase: { mutate: vi.fn() },
|
||||
deleteAgentFile: { mutate: vi.fn() },
|
||||
deleteAgentKnowledgeBase: { mutate: vi.fn() },
|
||||
duplicateAgent: { mutate: vi.fn() },
|
||||
getAgentConfigById: { query: vi.fn() },
|
||||
getBuiltinAgent: { query: vi.fn() },
|
||||
getKnowledgeBasesAndFiles: { query: vi.fn() },
|
||||
queryAgents: { query: vi.fn() },
|
||||
removeAgent: { mutate: vi.fn() },
|
||||
toggleFile: { mutate: vi.fn() },
|
||||
toggleKnowledgeBase: { mutate: vi.fn() },
|
||||
updateAgentConfig: { mutate: vi.fn() },
|
||||
updateAgentPinned: { mutate: vi.fn() },
|
||||
},
|
||||
aiAgent: {
|
||||
execAgent: { mutate: vi.fn() },
|
||||
@@ -136,6 +145,27 @@ describe('agent command', () => {
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('not found'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should support --slug option', async () => {
|
||||
mockTrpcClient.agent.getBuiltinAgent.query.mockResolvedValue({
|
||||
id: 'resolved-id',
|
||||
model: 'gpt-4',
|
||||
title: 'Inbox Agent',
|
||||
});
|
||||
mockTrpcClient.agent.getAgentConfigById.query.mockResolvedValue({
|
||||
id: 'resolved-id',
|
||||
model: 'gpt-4',
|
||||
title: 'Inbox Agent',
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'agent', 'view', '--slug', 'inbox']);
|
||||
|
||||
expect(mockTrpcClient.agent.getBuiltinAgent.query).toHaveBeenCalledWith({ slug: 'inbox' });
|
||||
expect(mockTrpcClient.agent.getAgentConfigById.query).toHaveBeenCalledWith({
|
||||
agentId: 'resolved-id',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
@@ -186,6 +216,32 @@ describe('agent command', () => {
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('No changes'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should support --slug option', async () => {
|
||||
mockTrpcClient.agent.getBuiltinAgent.query.mockResolvedValue({
|
||||
id: 'resolved-id',
|
||||
title: 'Inbox Agent',
|
||||
});
|
||||
mockTrpcClient.agent.updateAgentConfig.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'edit',
|
||||
'--slug',
|
||||
'inbox',
|
||||
'--model',
|
||||
'gemini-3-pro',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agent.getBuiltinAgent.query).toHaveBeenCalledWith({ slug: 'inbox' });
|
||||
expect(mockTrpcClient.agent.updateAgentConfig.mutate).toHaveBeenCalledWith({
|
||||
agentId: 'resolved-id',
|
||||
value: { model: 'gemini-3-pro' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
@@ -355,6 +411,158 @@ describe('agent command', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('pin/unpin', () => {
|
||||
it('should pin an agent', async () => {
|
||||
mockTrpcClient.agent.updateAgentPinned.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'agent', 'pin', 'a1']);
|
||||
|
||||
expect(mockTrpcClient.agent.updateAgentPinned.mutate).toHaveBeenCalledWith({
|
||||
id: 'a1',
|
||||
pinned: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should unpin an agent', async () => {
|
||||
mockTrpcClient.agent.updateAgentPinned.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'agent', 'unpin', 'a1']);
|
||||
|
||||
expect(mockTrpcClient.agent.updateAgentPinned.mutate).toHaveBeenCalledWith({
|
||||
id: 'a1',
|
||||
pinned: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('kb-files', () => {
|
||||
it('should list kb and files', async () => {
|
||||
mockTrpcClient.agent.getKnowledgeBasesAndFiles.query.mockResolvedValue([
|
||||
{ enabled: true, id: 'f1', name: 'file.txt', type: 'file' },
|
||||
]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'agent', 'kb-files', 'a1']);
|
||||
|
||||
expect(mockTrpcClient.agent.getKnowledgeBasesAndFiles.query).toHaveBeenCalledWith({
|
||||
agentId: 'a1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should show empty message', async () => {
|
||||
mockTrpcClient.agent.getKnowledgeBasesAndFiles.query.mockResolvedValue([]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'agent', 'kb-files', 'a1']);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith('No knowledge bases or files found.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('add-file', () => {
|
||||
it('should add files to agent', async () => {
|
||||
mockTrpcClient.agent.createAgentFiles.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'agent', 'add-file', 'a1', '--file-ids', 'f1,f2']);
|
||||
|
||||
expect(mockTrpcClient.agent.createAgentFiles.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: 'a1', fileIds: ['f1', 'f2'] }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove-file', () => {
|
||||
it('should remove a file from agent', async () => {
|
||||
mockTrpcClient.agent.deleteAgentFile.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'agent', 'remove-file', 'a1', '--file-id', 'f1']);
|
||||
|
||||
expect(mockTrpcClient.agent.deleteAgentFile.mutate).toHaveBeenCalledWith({
|
||||
agentId: 'a1',
|
||||
fileId: 'f1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggle-file', () => {
|
||||
it('should toggle file with enable', async () => {
|
||||
mockTrpcClient.agent.toggleFile.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'toggle-file',
|
||||
'a1',
|
||||
'--file-id',
|
||||
'f1',
|
||||
'--enable',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agent.toggleFile.mutate).toHaveBeenCalledWith({
|
||||
agentId: 'a1',
|
||||
enabled: true,
|
||||
fileId: 'f1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('add-kb', () => {
|
||||
it('should add kb to agent', async () => {
|
||||
mockTrpcClient.agent.createAgentKnowledgeBase.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'agent', 'add-kb', 'a1', '--kb-id', 'kb1']);
|
||||
|
||||
expect(mockTrpcClient.agent.createAgentKnowledgeBase.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: 'a1', knowledgeBaseId: 'kb1' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove-kb', () => {
|
||||
it('should remove kb from agent', async () => {
|
||||
mockTrpcClient.agent.deleteAgentKnowledgeBase.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'agent', 'remove-kb', 'a1', '--kb-id', 'kb1']);
|
||||
|
||||
expect(mockTrpcClient.agent.deleteAgentKnowledgeBase.mutate).toHaveBeenCalledWith({
|
||||
agentId: 'a1',
|
||||
knowledgeBaseId: 'kb1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggle-kb', () => {
|
||||
it('should toggle kb with disable', async () => {
|
||||
mockTrpcClient.agent.toggleKnowledgeBase.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'toggle-kb',
|
||||
'a1',
|
||||
'--kb-id',
|
||||
'kb1',
|
||||
'--disable',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agent.toggleKnowledgeBase.mutate).toHaveBeenCalledWith({
|
||||
agentId: 'a1',
|
||||
enabled: false,
|
||||
knowledgeBaseId: 'kb1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('status', () => {
|
||||
it('should display operation status', async () => {
|
||||
mockTrpcClient.aiAgent.getOperationStatus.query.mockResolvedValue({
|
||||
|
||||
+261
-29
@@ -9,6 +9,30 @@ import { replayAgentEvents, streamAgentEvents } from '../utils/agentStream';
|
||||
import { confirm, outputJson, printTable, truncate } from '../utils/format';
|
||||
import { log, setVerbose } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* Resolve an agent identifier (agentId or slug) to a concrete agentId.
|
||||
* When a slug is provided, uses getBuiltinAgent to look up the agent.
|
||||
*/
|
||||
async function resolveAgentId(
|
||||
client: any,
|
||||
opts: { agentId?: string; slug?: string },
|
||||
): Promise<string> {
|
||||
if (opts.agentId) return opts.agentId;
|
||||
|
||||
if (opts.slug) {
|
||||
const agent = await client.agent.getBuiltinAgent.query({ slug: opts.slug });
|
||||
if (!agent) {
|
||||
log.error(`Agent not found for slug: ${opts.slug}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return (agent as any).id || (agent as any).agentId;
|
||||
}
|
||||
|
||||
log.error('Either <agentId> or --slug is required.');
|
||||
process.exit(1);
|
||||
return ''; // unreachable
|
||||
}
|
||||
|
||||
export function registerAgentCommand(program: Command) {
|
||||
const agent = program.command('agent').description('Manage agents');
|
||||
|
||||
@@ -54,39 +78,46 @@ export function registerAgentCommand(program: Command) {
|
||||
// ── view ──────────────────────────────────────────────
|
||||
|
||||
agent
|
||||
.command('view <agentId>')
|
||||
.command('view [agentId]')
|
||||
.description('View agent configuration')
|
||||
.option('-s, --slug <slug>', 'Agent slug (e.g. inbox)')
|
||||
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
|
||||
.action(async (agentId: string, options: { json?: string | boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.agent.getAgentConfigById.query({ agentId });
|
||||
.action(
|
||||
async (
|
||||
agentIdArg: string | undefined,
|
||||
options: { json?: string | boolean; slug?: string },
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
const agentId = await resolveAgentId(client, { agentId: agentIdArg, slug: options.slug });
|
||||
const result = await client.agent.getAgentConfigById.query({ agentId });
|
||||
|
||||
if (!result) {
|
||||
log.error(`Agent not found: ${agentId}`);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
if (!result) {
|
||||
log.error(`Agent not found: ${agentId}`);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(result, fields);
|
||||
return;
|
||||
}
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(result, fields);
|
||||
return;
|
||||
}
|
||||
|
||||
const r = result as any;
|
||||
console.log(pc.bold(r.title || r.meta?.title || 'Untitled'));
|
||||
const meta: string[] = [];
|
||||
if (r.description || r.meta?.description) meta.push(r.description || r.meta.description);
|
||||
if (r.model) meta.push(`Model: ${r.model}`);
|
||||
if (r.provider) meta.push(`Provider: ${r.provider}`);
|
||||
if (meta.length > 0) console.log(pc.dim(meta.join(' · ')));
|
||||
const r = result as any;
|
||||
console.log(pc.bold(r.title || r.meta?.title || 'Untitled'));
|
||||
const meta: string[] = [];
|
||||
if (r.description || r.meta?.description) meta.push(r.description || r.meta.description);
|
||||
if (r.model) meta.push(`Model: ${r.model}`);
|
||||
if (r.provider) meta.push(`Provider: ${r.provider}`);
|
||||
if (meta.length > 0) console.log(pc.dim(meta.join(' · ')));
|
||||
|
||||
if (r.systemRole) {
|
||||
console.log();
|
||||
console.log(pc.bold('System Role:'));
|
||||
console.log(r.systemRole);
|
||||
}
|
||||
});
|
||||
if (r.systemRole) {
|
||||
console.log();
|
||||
console.log(pc.bold('System Role:'));
|
||||
console.log(r.systemRole);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ── create ────────────────────────────────────────────
|
||||
|
||||
@@ -130,8 +161,9 @@ export function registerAgentCommand(program: Command) {
|
||||
// ── edit ──────────────────────────────────────────────
|
||||
|
||||
agent
|
||||
.command('edit <agentId>')
|
||||
.command('edit [agentId]')
|
||||
.description('Update agent configuration')
|
||||
.option('--slug <slug>', 'Agent slug (e.g. inbox)')
|
||||
.option('-t, --title <title>', 'New title')
|
||||
.option('-d, --description <desc>', 'New description')
|
||||
.option('-m, --model <model>', 'New model ID')
|
||||
@@ -139,11 +171,12 @@ export function registerAgentCommand(program: Command) {
|
||||
.option('-s, --system-role <role>', 'New system role prompt')
|
||||
.action(
|
||||
async (
|
||||
agentId: string,
|
||||
agentIdArg: string | undefined,
|
||||
options: {
|
||||
description?: string;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
slug?: string;
|
||||
systemRole?: string;
|
||||
title?: string;
|
||||
},
|
||||
@@ -163,6 +196,7 @@ export function registerAgentCommand(program: Command) {
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
const agentId = await resolveAgentId(client, { agentId: agentIdArg, slug: options.slug });
|
||||
await client.agent.updateAgentConfig.mutate({ agentId, value });
|
||||
console.log(`${pc.green('✓')} Updated agent ${pc.bold(agentId)}`);
|
||||
},
|
||||
@@ -282,6 +316,204 @@ export function registerAgentCommand(program: Command) {
|
||||
},
|
||||
);
|
||||
|
||||
// ── pin / unpin ─────────────────────────────────────
|
||||
|
||||
agent
|
||||
.command('pin <agentId>')
|
||||
.description('Pin an agent')
|
||||
.action(async (agentId: string) => {
|
||||
const client = await getTrpcClient();
|
||||
await client.agent.updateAgentPinned.mutate({ id: agentId, pinned: true });
|
||||
console.log(`${pc.green('✓')} Pinned agent ${pc.bold(agentId)}`);
|
||||
});
|
||||
|
||||
agent
|
||||
.command('unpin <agentId>')
|
||||
.description('Unpin an agent')
|
||||
.action(async (agentId: string) => {
|
||||
const client = await getTrpcClient();
|
||||
await client.agent.updateAgentPinned.mutate({ id: agentId, pinned: false });
|
||||
console.log(`${pc.green('✓')} Unpinned agent ${pc.bold(agentId)}`);
|
||||
});
|
||||
|
||||
// ── kb-files ───────────────────────────────────────
|
||||
|
||||
agent
|
||||
.command('kb-files [agentId]')
|
||||
.description('List knowledge bases and files associated with an agent')
|
||||
.option('-s, --slug <slug>', 'Agent slug')
|
||||
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
|
||||
.action(
|
||||
async (
|
||||
agentIdArg: string | undefined,
|
||||
options: { json?: string | boolean; slug?: string },
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
const agentId = await resolveAgentId(client, { agentId: agentIdArg, slug: options.slug });
|
||||
const items = await client.agent.getKnowledgeBasesAndFiles.query({ agentId });
|
||||
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(items, fields);
|
||||
return;
|
||||
}
|
||||
|
||||
const list = Array.isArray(items) ? items : [];
|
||||
if (list.length === 0) {
|
||||
console.log('No knowledge bases or files found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = list.map((item: any) => [
|
||||
item.id || '',
|
||||
truncate(item.name || '', 40),
|
||||
item.type || '',
|
||||
item.enabled ? 'enabled' : 'disabled',
|
||||
]);
|
||||
|
||||
printTable(rows, ['ID', 'NAME', 'TYPE', 'STATUS']);
|
||||
},
|
||||
);
|
||||
|
||||
// ── add-file ───────────────────────────────────────
|
||||
|
||||
agent
|
||||
.command('add-file [agentId]')
|
||||
.description('Associate files with an agent')
|
||||
.option('-s, --slug <slug>', 'Agent slug')
|
||||
.requiredOption('--file-ids <ids>', 'Comma-separated file IDs')
|
||||
.option('--enabled', 'Enable files immediately')
|
||||
.action(
|
||||
async (
|
||||
agentIdArg: string | undefined,
|
||||
options: { enabled?: boolean; fileIds: string; slug?: string },
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
const agentId = await resolveAgentId(client, { agentId: agentIdArg, slug: options.slug });
|
||||
const fileIds = options.fileIds.split(',').map((s) => s.trim());
|
||||
|
||||
const input: Record<string, any> = { agentId, fileIds };
|
||||
if (options.enabled !== undefined) input.enabled = options.enabled;
|
||||
|
||||
await client.agent.createAgentFiles.mutate(input as any);
|
||||
console.log(
|
||||
`${pc.green('✓')} Added ${fileIds.length} file(s) to agent ${pc.bold(agentId)}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// ── remove-file ────────────────────────────────────
|
||||
|
||||
agent
|
||||
.command('remove-file [agentId]')
|
||||
.description('Remove a file from an agent')
|
||||
.option('-s, --slug <slug>', 'Agent slug')
|
||||
.requiredOption('--file-id <id>', 'File ID to remove')
|
||||
.action(async (agentIdArg: string | undefined, options: { fileId: string; slug?: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
const agentId = await resolveAgentId(client, { agentId: agentIdArg, slug: options.slug });
|
||||
await client.agent.deleteAgentFile.mutate({ agentId, fileId: options.fileId });
|
||||
console.log(
|
||||
`${pc.green('✓')} Removed file ${pc.bold(options.fileId)} from agent ${pc.bold(agentId)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// ── toggle-file ────────────────────────────────────
|
||||
|
||||
agent
|
||||
.command('toggle-file [agentId]')
|
||||
.description('Toggle a file on/off for an agent')
|
||||
.option('-s, --slug <slug>', 'Agent slug')
|
||||
.requiredOption('--file-id <id>', 'File ID')
|
||||
.option('--enable', 'Enable the file')
|
||||
.option('--disable', 'Disable the file')
|
||||
.action(
|
||||
async (
|
||||
agentIdArg: string | undefined,
|
||||
options: { disable?: boolean; enable?: boolean; fileId: string; slug?: string },
|
||||
) => {
|
||||
const enabled = options.enable ? true : options.disable ? false : undefined;
|
||||
const client = await getTrpcClient();
|
||||
const agentId = await resolveAgentId(client, { agentId: agentIdArg, slug: options.slug });
|
||||
await client.agent.toggleFile.mutate({ agentId, enabled, fileId: options.fileId });
|
||||
console.log(
|
||||
`${pc.green('✓')} Toggled file ${pc.bold(options.fileId)} for agent ${pc.bold(agentId)}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// ── add-kb ─────────────────────────────────────────
|
||||
|
||||
agent
|
||||
.command('add-kb [agentId]')
|
||||
.description('Associate a knowledge base with an agent')
|
||||
.option('-s, --slug <slug>', 'Agent slug')
|
||||
.requiredOption('--kb-id <id>', 'Knowledge base ID')
|
||||
.option('--enabled', 'Enable immediately')
|
||||
.action(
|
||||
async (
|
||||
agentIdArg: string | undefined,
|
||||
options: { enabled?: boolean; kbId: string; slug?: string },
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
const agentId = await resolveAgentId(client, { agentId: agentIdArg, slug: options.slug });
|
||||
const input: Record<string, any> = { agentId, knowledgeBaseId: options.kbId };
|
||||
if (options.enabled !== undefined) input.enabled = options.enabled;
|
||||
|
||||
await client.agent.createAgentKnowledgeBase.mutate(input as any);
|
||||
console.log(
|
||||
`${pc.green('✓')} Added knowledge base ${pc.bold(options.kbId)} to agent ${pc.bold(agentId)}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// ── remove-kb ──────────────────────────────────────
|
||||
|
||||
agent
|
||||
.command('remove-kb [agentId]')
|
||||
.description('Remove a knowledge base from an agent')
|
||||
.option('-s, --slug <slug>', 'Agent slug')
|
||||
.requiredOption('--kb-id <id>', 'Knowledge base ID')
|
||||
.action(async (agentIdArg: string | undefined, options: { kbId: string; slug?: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
const agentId = await resolveAgentId(client, { agentId: agentIdArg, slug: options.slug });
|
||||
await client.agent.deleteAgentKnowledgeBase.mutate({
|
||||
agentId,
|
||||
knowledgeBaseId: options.kbId,
|
||||
});
|
||||
console.log(
|
||||
`${pc.green('✓')} Removed knowledge base ${pc.bold(options.kbId)} from agent ${pc.bold(agentId)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// ── toggle-kb ──────────────────────────────────────
|
||||
|
||||
agent
|
||||
.command('toggle-kb [agentId]')
|
||||
.description('Toggle a knowledge base on/off for an agent')
|
||||
.option('-s, --slug <slug>', 'Agent slug')
|
||||
.requiredOption('--kb-id <id>', 'Knowledge base ID')
|
||||
.option('--enable', 'Enable the knowledge base')
|
||||
.option('--disable', 'Disable the knowledge base')
|
||||
.action(
|
||||
async (
|
||||
agentIdArg: string | undefined,
|
||||
options: { disable?: boolean; enable?: boolean; kbId: string; slug?: string },
|
||||
) => {
|
||||
const enabled = options.enable ? true : options.disable ? false : undefined;
|
||||
const client = await getTrpcClient();
|
||||
const agentId = await resolveAgentId(client, { agentId: agentIdArg, slug: options.slug });
|
||||
await client.agent.toggleKnowledgeBase.mutate({
|
||||
agentId,
|
||||
enabled,
|
||||
knowledgeBaseId: options.kbId,
|
||||
});
|
||||
console.log(
|
||||
`${pc.green('✓')} Toggled knowledge base ${pc.bold(options.kbId)} for agent ${pc.bold(agentId)}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// ── status ──────────────────────────────────────────
|
||||
|
||||
agent
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { log } from '../utils/logger';
|
||||
import { registerBotCommand } from './bot';
|
||||
|
||||
const { mockTrpcClient } = vi.hoisted(() => ({
|
||||
mockTrpcClient: {
|
||||
agentBotProvider: {
|
||||
connectBot: { mutate: vi.fn() },
|
||||
create: { mutate: vi.fn() },
|
||||
delete: { mutate: vi.fn() },
|
||||
getByAgentId: { query: vi.fn() },
|
||||
list: { query: vi.fn() },
|
||||
update: { mutate: vi.fn() },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const { getTrpcClient: mockGetTrpcClient } = vi.hoisted(() => ({
|
||||
getTrpcClient: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../api/client', () => ({ getTrpcClient: mockGetTrpcClient }));
|
||||
vi.mock('../utils/logger', () => ({
|
||||
log: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() },
|
||||
setVerbose: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('bot command', () => {
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
|
||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
mockGetTrpcClient.mockResolvedValue(mockTrpcClient);
|
||||
for (const method of Object.values(mockTrpcClient.agentBotProvider)) {
|
||||
for (const fn of Object.values(method)) {
|
||||
(fn as ReturnType<typeof vi.fn>).mockReset();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
exitSpy.mockRestore();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
function createProgram() {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerBotCommand(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
describe('list', () => {
|
||||
it('should list all bot integrations', async () => {
|
||||
mockTrpcClient.agentBotProvider.list.query.mockResolvedValue([
|
||||
{
|
||||
agentId: 'agent1',
|
||||
applicationId: 'app123',
|
||||
enabled: true,
|
||||
id: 'b1',
|
||||
platform: 'discord',
|
||||
},
|
||||
]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'bot', 'list']);
|
||||
|
||||
expect(mockTrpcClient.agentBotProvider.list.query).toHaveBeenCalledWith({});
|
||||
expect(consoleSpy).toHaveBeenCalledTimes(2); // header + 1 row
|
||||
expect(consoleSpy.mock.calls[0][0]).toContain('ID');
|
||||
});
|
||||
|
||||
it('should filter by agent', async () => {
|
||||
mockTrpcClient.agentBotProvider.list.query.mockResolvedValue([]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'bot', 'list', '--agent', 'agent1']);
|
||||
|
||||
expect(mockTrpcClient.agentBotProvider.list.query).toHaveBeenCalledWith({
|
||||
agentId: 'agent1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter by platform', async () => {
|
||||
mockTrpcClient.agentBotProvider.list.query.mockResolvedValue([]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'bot', 'list', '--platform', 'discord']);
|
||||
|
||||
expect(mockTrpcClient.agentBotProvider.list.query).toHaveBeenCalledWith({
|
||||
platform: 'discord',
|
||||
});
|
||||
});
|
||||
|
||||
it('should output JSON', async () => {
|
||||
const items = [{ id: 'b1', platform: 'discord' }];
|
||||
mockTrpcClient.agentBotProvider.list.query.mockResolvedValue(items);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'bot', 'list', '--json']);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(items, null, 2));
|
||||
});
|
||||
|
||||
it('should show message when no bots found', async () => {
|
||||
mockTrpcClient.agentBotProvider.list.query.mockResolvedValue([]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'bot', 'list']);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith('No bot integrations found.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('view', () => {
|
||||
it('should display bot details', async () => {
|
||||
mockTrpcClient.agentBotProvider.getByAgentId.query.mockResolvedValue([
|
||||
{
|
||||
applicationId: 'app123',
|
||||
credentials: { botToken: 'tok_12345678' },
|
||||
enabled: true,
|
||||
id: 'b1',
|
||||
platform: 'discord',
|
||||
},
|
||||
]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'bot', 'view', 'b1', '--agent', 'agent1']);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('discord'));
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('app123'));
|
||||
});
|
||||
|
||||
it('should error when bot not found', async () => {
|
||||
mockTrpcClient.agentBotProvider.getByAgentId.query.mockResolvedValue([]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'bot', 'view', 'nonexistent', '--agent', 'agent1']);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('not found'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('add', () => {
|
||||
it('should add a discord bot', async () => {
|
||||
mockTrpcClient.agentBotProvider.create.mutate.mockResolvedValue({ id: 'new-bot' });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'bot',
|
||||
'add',
|
||||
'--agent',
|
||||
'agent1',
|
||||
'--platform',
|
||||
'discord',
|
||||
'--app-id',
|
||||
'app123',
|
||||
'--bot-token',
|
||||
'tok123',
|
||||
'--public-key',
|
||||
'pk123',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentBotProvider.create.mutate).toHaveBeenCalledWith({
|
||||
agentId: 'agent1',
|
||||
applicationId: 'app123',
|
||||
credentials: { botToken: 'tok123', publicKey: 'pk123' },
|
||||
platform: 'discord',
|
||||
});
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Added'));
|
||||
});
|
||||
|
||||
it('should add a telegram bot', async () => {
|
||||
mockTrpcClient.agentBotProvider.create.mutate.mockResolvedValue({ id: 'new-bot' });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'bot',
|
||||
'add',
|
||||
'--agent',
|
||||
'agent1',
|
||||
'--platform',
|
||||
'telegram',
|
||||
'--app-id',
|
||||
'tg123',
|
||||
'--bot-token',
|
||||
'tok123',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentBotProvider.create.mutate).toHaveBeenCalledWith({
|
||||
agentId: 'agent1',
|
||||
applicationId: 'tg123',
|
||||
credentials: { botToken: 'tok123' },
|
||||
platform: 'telegram',
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject invalid platform', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'bot',
|
||||
'add',
|
||||
'--agent',
|
||||
'agent1',
|
||||
'--platform',
|
||||
'invalid',
|
||||
'--app-id',
|
||||
'x',
|
||||
'--bot-token',
|
||||
'x',
|
||||
]);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Invalid platform'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should reject missing required credentials', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'bot',
|
||||
'add',
|
||||
'--agent',
|
||||
'agent1',
|
||||
'--platform',
|
||||
'discord',
|
||||
'--app-id',
|
||||
'app123',
|
||||
'--bot-token',
|
||||
'tok123',
|
||||
// missing --public-key
|
||||
]);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Missing required'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update bot credentials', async () => {
|
||||
mockTrpcClient.agentBotProvider.update.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'bot', 'update', 'b1', '--bot-token', 'new-token']);
|
||||
|
||||
expect(mockTrpcClient.agentBotProvider.update.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
credentials: { botToken: 'new-token' },
|
||||
id: 'b1',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should error when no changes specified', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'bot', 'update', 'b1']);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('No changes'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('should remove with --yes', async () => {
|
||||
mockTrpcClient.agentBotProvider.delete.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'bot', 'remove', 'b1', '--yes']);
|
||||
|
||||
expect(mockTrpcClient.agentBotProvider.delete.mutate).toHaveBeenCalledWith({ id: 'b1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('enable / disable', () => {
|
||||
it('should enable a bot', async () => {
|
||||
mockTrpcClient.agentBotProvider.update.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'bot', 'enable', 'b1']);
|
||||
|
||||
expect(mockTrpcClient.agentBotProvider.update.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ enabled: true, id: 'b1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should disable a bot', async () => {
|
||||
mockTrpcClient.agentBotProvider.update.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'bot', 'disable', 'b1']);
|
||||
|
||||
expect(mockTrpcClient.agentBotProvider.update.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ enabled: false, id: 'b1' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('connect', () => {
|
||||
it('should connect a bot', async () => {
|
||||
mockTrpcClient.agentBotProvider.getByAgentId.query.mockResolvedValue([
|
||||
{ applicationId: 'app123', id: 'b1', platform: 'discord' },
|
||||
]);
|
||||
mockTrpcClient.agentBotProvider.connectBot.mutate.mockResolvedValue({ status: 'connected' });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'bot', 'connect', 'b1', '--agent', 'agent1']);
|
||||
|
||||
expect(mockTrpcClient.agentBotProvider.connectBot.mutate).toHaveBeenCalledWith({
|
||||
applicationId: 'app123',
|
||||
platform: 'discord',
|
||||
});
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Connected'));
|
||||
});
|
||||
|
||||
it('should error when bot not found', async () => {
|
||||
mockTrpcClient.agentBotProvider.getByAgentId.query.mockResolvedValue([]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'bot',
|
||||
'connect',
|
||||
'nonexistent',
|
||||
'--agent',
|
||||
'agent1',
|
||||
]);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('not found'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,298 @@
|
||||
import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../api/client';
|
||||
import { confirm, outputJson, printTable } from '../utils/format';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
const SUPPORTED_PLATFORMS = ['discord', 'slack', 'telegram', 'lark', 'feishu'];
|
||||
|
||||
const PLATFORM_CREDENTIAL_FIELDS: Record<string, string[]> = {
|
||||
discord: ['botToken', 'publicKey'],
|
||||
feishu: ['appId', 'appSecret'],
|
||||
lark: ['appId', 'appSecret'],
|
||||
slack: ['botToken', 'signingSecret'],
|
||||
telegram: ['botToken'],
|
||||
};
|
||||
|
||||
function parseCredentials(
|
||||
platform: string,
|
||||
options: Record<string, string | undefined>,
|
||||
): Record<string, string> {
|
||||
const creds: Record<string, string> = {};
|
||||
|
||||
if (options.botToken) creds.botToken = options.botToken;
|
||||
if (options.publicKey) creds.publicKey = options.publicKey;
|
||||
if (options.signingSecret) creds.signingSecret = options.signingSecret;
|
||||
if (options.appSecret) creds.appSecret = options.appSecret;
|
||||
|
||||
// For lark/feishu, --app-id maps to credentials.appId (distinct from --app-id as applicationId)
|
||||
if ((platform === 'lark' || platform === 'feishu') && options.appId) {
|
||||
creds.appId = options.appId;
|
||||
}
|
||||
|
||||
return creds;
|
||||
}
|
||||
|
||||
export function registerBotCommand(program: Command) {
|
||||
const bot = program.command('bot').description('Manage bot integrations');
|
||||
|
||||
// ── list ──────────────────────────────────────────────
|
||||
|
||||
bot
|
||||
.command('list')
|
||||
.description('List bot integrations')
|
||||
.option('-a, --agent <agentId>', 'Filter by agent ID')
|
||||
.option('--platform <platform>', 'Filter by platform')
|
||||
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
|
||||
.action(async (options: { agent?: string; json?: string | boolean; platform?: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
const input: { agentId?: string; platform?: string } = {};
|
||||
if (options.agent) input.agentId = options.agent;
|
||||
if (options.platform) input.platform = options.platform;
|
||||
|
||||
const result = await client.agentBotProvider.list.query(input);
|
||||
const items = Array.isArray(result) ? result : [];
|
||||
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(items, fields);
|
||||
return;
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
console.log('No bot integrations found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = items.map((b: any) => [
|
||||
b.id || '',
|
||||
b.platform || '',
|
||||
b.applicationId || '',
|
||||
b.agentId || '',
|
||||
b.enabled ? pc.green('enabled') : pc.dim('disabled'),
|
||||
]);
|
||||
|
||||
printTable(rows, ['ID', 'PLATFORM', 'APP ID', 'AGENT', 'STATUS']);
|
||||
});
|
||||
|
||||
// ── view ──────────────────────────────────────────────
|
||||
|
||||
bot
|
||||
.command('view <botId>')
|
||||
.description('View bot integration details')
|
||||
.requiredOption('-a, --agent <agentId>', 'Agent ID')
|
||||
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
|
||||
.action(async (botId: string, options: { agent: string; json?: string | boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.agentBotProvider.getByAgentId.query({
|
||||
agentId: options.agent,
|
||||
});
|
||||
const items = Array.isArray(result) ? result : [];
|
||||
const item = items.find((b: any) => b.id === botId);
|
||||
|
||||
if (!item) {
|
||||
log.error(`Bot integration not found: ${botId}`);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(item, fields);
|
||||
return;
|
||||
}
|
||||
|
||||
const b = item as any;
|
||||
console.log(pc.bold(`${b.platform} bot`));
|
||||
console.log(pc.dim(`ID: ${b.id}`));
|
||||
console.log(`Application ID: ${b.applicationId}`);
|
||||
console.log(`Status: ${b.enabled ? pc.green('enabled') : pc.dim('disabled')}`);
|
||||
|
||||
if (b.credentials && typeof b.credentials === 'object') {
|
||||
console.log();
|
||||
console.log(pc.bold('Credentials:'));
|
||||
for (const [key, value] of Object.entries(b.credentials)) {
|
||||
const val = String(value);
|
||||
const masked = val.length > 8 ? val.slice(0, 4) + '****' + val.slice(-4) : '****';
|
||||
console.log(` ${key}: ${masked}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── add ───────────────────────────────────────────────
|
||||
|
||||
bot
|
||||
.command('add')
|
||||
.description('Add a bot integration to an agent')
|
||||
.requiredOption('-a, --agent <agentId>', 'Agent ID')
|
||||
.requiredOption('--platform <platform>', `Platform: ${SUPPORTED_PLATFORMS.join(', ')}`)
|
||||
.requiredOption('--app-id <appId>', 'Application ID for webhook routing')
|
||||
.option('--bot-token <token>', 'Bot token')
|
||||
.option('--public-key <key>', 'Public key (Discord)')
|
||||
.option('--signing-secret <secret>', 'Signing secret (Slack)')
|
||||
.option('--app-secret <secret>', 'App secret (Lark/Feishu)')
|
||||
.action(
|
||||
async (options: {
|
||||
agent: string;
|
||||
appId: string;
|
||||
appSecret?: string;
|
||||
botToken?: string;
|
||||
platform: string;
|
||||
publicKey?: string;
|
||||
signingSecret?: string;
|
||||
}) => {
|
||||
if (!SUPPORTED_PLATFORMS.includes(options.platform)) {
|
||||
log.error(`Invalid platform. Must be one of: ${SUPPORTED_PLATFORMS.join(', ')}`);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const credentials = parseCredentials(options.platform, options);
|
||||
const requiredFields = PLATFORM_CREDENTIAL_FIELDS[options.platform] || [];
|
||||
const missing = requiredFields.filter((f) => !credentials[f]);
|
||||
if (missing.length > 0) {
|
||||
log.error(
|
||||
`Missing required credentials for ${options.platform}: ${missing.map((f) => '--' + f.replaceAll(/([A-Z])/g, '-$1').toLowerCase()).join(', ')}`,
|
||||
);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.agentBotProvider.create.mutate({
|
||||
agentId: options.agent,
|
||||
applicationId: options.appId,
|
||||
credentials,
|
||||
platform: options.platform,
|
||||
});
|
||||
const r = result as any;
|
||||
console.log(
|
||||
`${pc.green('✓')} Added ${pc.bold(options.platform)} bot ${pc.bold(r.id || '')}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// ── update ────────────────────────────────────────────
|
||||
|
||||
bot
|
||||
.command('update <botId>')
|
||||
.description('Update a bot integration')
|
||||
.option('--bot-token <token>', 'New bot token')
|
||||
.option('--public-key <key>', 'New public key')
|
||||
.option('--signing-secret <secret>', 'New signing secret')
|
||||
.option('--app-secret <secret>', 'New app secret')
|
||||
.option('--app-id <appId>', 'New application ID')
|
||||
.option('--platform <platform>', 'New platform')
|
||||
.action(
|
||||
async (
|
||||
botId: string,
|
||||
options: {
|
||||
appId?: string;
|
||||
appSecret?: string;
|
||||
botToken?: string;
|
||||
platform?: string;
|
||||
publicKey?: string;
|
||||
signingSecret?: string;
|
||||
},
|
||||
) => {
|
||||
const input: Record<string, any> = { id: botId };
|
||||
|
||||
const credentials: Record<string, string> = {};
|
||||
if (options.botToken) credentials.botToken = options.botToken;
|
||||
if (options.publicKey) credentials.publicKey = options.publicKey;
|
||||
if (options.signingSecret) credentials.signingSecret = options.signingSecret;
|
||||
if (options.appSecret) credentials.appSecret = options.appSecret;
|
||||
|
||||
if (Object.keys(credentials).length > 0) input.credentials = credentials;
|
||||
if (options.appId) input.applicationId = options.appId;
|
||||
if (options.platform) input.platform = options.platform;
|
||||
|
||||
if (Object.keys(input).length <= 1) {
|
||||
log.error('No changes specified.');
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
await client.agentBotProvider.update.mutate(input as any);
|
||||
console.log(`${pc.green('✓')} Updated bot ${pc.bold(botId)}`);
|
||||
},
|
||||
);
|
||||
|
||||
// ── remove ────────────────────────────────────────────
|
||||
|
||||
bot
|
||||
.command('remove <botId>')
|
||||
.description('Remove a bot integration')
|
||||
.option('--yes', 'Skip confirmation prompt')
|
||||
.action(async (botId: string, options: { yes?: boolean }) => {
|
||||
if (!options.yes) {
|
||||
const confirmed = await confirm('Are you sure you want to remove this bot integration?');
|
||||
if (!confirmed) {
|
||||
console.log('Cancelled.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
await client.agentBotProvider.delete.mutate({ id: botId });
|
||||
console.log(`${pc.green('✓')} Removed bot ${pc.bold(botId)}`);
|
||||
});
|
||||
|
||||
// ── enable / disable ──────────────────────────────────
|
||||
|
||||
bot
|
||||
.command('enable <botId>')
|
||||
.description('Enable a bot integration')
|
||||
.action(async (botId: string) => {
|
||||
const client = await getTrpcClient();
|
||||
await client.agentBotProvider.update.mutate({ enabled: true, id: botId } as any);
|
||||
console.log(`${pc.green('✓')} Enabled bot ${pc.bold(botId)}`);
|
||||
});
|
||||
|
||||
bot
|
||||
.command('disable <botId>')
|
||||
.description('Disable a bot integration')
|
||||
.action(async (botId: string) => {
|
||||
const client = await getTrpcClient();
|
||||
await client.agentBotProvider.update.mutate({ enabled: false, id: botId } as any);
|
||||
console.log(`${pc.green('✓')} Disabled bot ${pc.bold(botId)}`);
|
||||
});
|
||||
|
||||
// ── connect ───────────────────────────────────────────
|
||||
|
||||
bot
|
||||
.command('connect <botId>')
|
||||
.description('Connect and start a bot')
|
||||
.requiredOption('-a, --agent <agentId>', 'Agent ID')
|
||||
.action(async (botId: string, options: { agent: string }) => {
|
||||
// First fetch the bot to get platform and applicationId
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.agentBotProvider.getByAgentId.query({
|
||||
agentId: options.agent,
|
||||
});
|
||||
const items = Array.isArray(result) ? result : [];
|
||||
const item = items.find((b: any) => b.id === botId);
|
||||
|
||||
if (!item) {
|
||||
log.error(`Bot integration not found: ${botId}`);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const b = item as any;
|
||||
const connectResult = await client.agentBotProvider.connectBot.mutate({
|
||||
applicationId: b.applicationId,
|
||||
platform: b.platform,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`${pc.green('✓')} Connected ${pc.bold(b.platform)} bot ${pc.bold(b.applicationId)}`,
|
||||
);
|
||||
if ((connectResult as any)?.status) {
|
||||
console.log(` Status: ${(connectResult as any).status}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { registerConfigCommand } from './config';
|
||||
const { mockTrpcClient } = vi.hoisted(() => ({
|
||||
mockTrpcClient: {
|
||||
usage: {
|
||||
findAndGroupByDateRange: { query: vi.fn() },
|
||||
findAndGroupByDay: { query: vi.fn() },
|
||||
findByMonth: { query: vi.fn() },
|
||||
},
|
||||
@@ -34,6 +35,8 @@ describe('config command', () => {
|
||||
mockTrpcClient.user.getUserState.query.mockReset();
|
||||
mockTrpcClient.usage.findByMonth.query.mockReset();
|
||||
mockTrpcClient.usage.findAndGroupByDay.query.mockReset();
|
||||
mockTrpcClient.usage.findAndGroupByDateRange.query.mockReset();
|
||||
mockTrpcClient.usage.findAndGroupByDateRange.query.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -75,36 +78,34 @@ describe('config command', () => {
|
||||
});
|
||||
|
||||
describe('usage', () => {
|
||||
it('should display monthly usage', async () => {
|
||||
mockTrpcClient.usage.findByMonth.query.mockResolvedValue({ totalTokens: 1000 });
|
||||
it('should display usage table', async () => {
|
||||
mockTrpcClient.usage.findAndGroupByDay.query.mockResolvedValue([
|
||||
{
|
||||
day: '2024-01-15',
|
||||
records: [{ model: 'claude-opus-4-6', totalInputTokens: 500, totalOutputTokens: 500 }],
|
||||
totalRequests: 1,
|
||||
totalSpend: 0.5,
|
||||
totalTokens: 1000,
|
||||
},
|
||||
]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'usage']);
|
||||
|
||||
expect(mockTrpcClient.usage.findByMonth.query).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should display daily usage', async () => {
|
||||
mockTrpcClient.usage.findAndGroupByDay.query.mockResolvedValue([
|
||||
{ date: '2024-01-01', totalTokens: 100 },
|
||||
]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'usage', '--daily']);
|
||||
|
||||
expect(mockTrpcClient.usage.findAndGroupByDay.query).toHaveBeenCalled();
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('2024-01-15'));
|
||||
});
|
||||
|
||||
it('should pass month param', async () => {
|
||||
mockTrpcClient.usage.findByMonth.query.mockResolvedValue({});
|
||||
mockTrpcClient.usage.findAndGroupByDay.query.mockResolvedValue([]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'usage', '--month', '2024-01']);
|
||||
|
||||
expect(mockTrpcClient.usage.findByMonth.query).toHaveBeenCalledWith({ mo: '2024-01' });
|
||||
expect(mockTrpcClient.usage.findAndGroupByDay.query).toHaveBeenCalledWith({ mo: '2024-01' });
|
||||
});
|
||||
|
||||
it('should output JSON', async () => {
|
||||
it('should output JSON with --json flag', async () => {
|
||||
const data = { totalTokens: 1000 };
|
||||
mockTrpcClient.usage.findByMonth.query.mockResolvedValue(data);
|
||||
|
||||
@@ -113,5 +114,16 @@ describe('config command', () => {
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(data, null, 2));
|
||||
});
|
||||
|
||||
it('should output JSON daily with --json --daily', async () => {
|
||||
const data = [{ day: '2024-01-01', totalTokens: 100 }];
|
||||
mockTrpcClient.usage.findAndGroupByDay.query.mockResolvedValue(data);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'usage', '--json', '--daily']);
|
||||
|
||||
expect(mockTrpcClient.usage.findAndGroupByDay.query).toHaveBeenCalled();
|
||||
expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(data, null, 2));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+138
-20
@@ -2,7 +2,14 @@ import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../api/client';
|
||||
import { outputJson } from '../utils/format';
|
||||
import {
|
||||
type BoxTableRow,
|
||||
formatCost,
|
||||
formatNumber,
|
||||
outputJson,
|
||||
printBoxTable,
|
||||
printCalendarHeatmap,
|
||||
} from '../utils/format';
|
||||
|
||||
export function registerConfigCommand(program: Command) {
|
||||
// ── whoami ────────────────────────────────────────────
|
||||
@@ -44,35 +51,146 @@ export function registerConfigCommand(program: Command) {
|
||||
const input: { mo?: string } = {};
|
||||
if (options.month) input.mo = options.month;
|
||||
|
||||
let result: any;
|
||||
if (options.daily) {
|
||||
result = await client.usage.findAndGroupByDay.query(input);
|
||||
} else {
|
||||
result = await client.usage.findByMonth.query(input);
|
||||
}
|
||||
|
||||
if (options.json !== undefined) {
|
||||
let jsonResult: any;
|
||||
if (options.daily) {
|
||||
jsonResult = await client.usage.findAndGroupByDay.query(input);
|
||||
} else {
|
||||
jsonResult = await client.usage.findByMonth.query(input);
|
||||
}
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(result, fields);
|
||||
outputJson(jsonResult, fields);
|
||||
return;
|
||||
}
|
||||
|
||||
// Always fetch daily-grouped data for table display
|
||||
const result: any = await client.usage.findAndGroupByDay.query(input);
|
||||
|
||||
if (!result) {
|
||||
console.log('No usage data available.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.daily && Array.isArray(result)) {
|
||||
console.log(pc.bold('Daily Usage'));
|
||||
for (const entry of result) {
|
||||
const e = entry as any;
|
||||
const day = e.date || e.day || '';
|
||||
const tokens = e.totalTokens || e.tokens || 0;
|
||||
console.log(` ${day}: ${tokens} tokens`);
|
||||
}
|
||||
} else {
|
||||
console.log(pc.bold('Monthly Usage'));
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
// Normalize result to an array of daily logs
|
||||
const logs: any[] = Array.isArray(result) ? result : [result];
|
||||
|
||||
// Filter out days with zero activity for cleaner output
|
||||
const activeLogs = logs.filter(
|
||||
(l: any) => (l.totalTokens || 0) > 0 || (l.totalRequests || 0) > 0,
|
||||
);
|
||||
|
||||
if (activeLogs.length === 0) {
|
||||
console.log('No usage data available.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Build table columns
|
||||
const columns = [
|
||||
{ align: 'left' as const, header: 'Date', key: 'date' },
|
||||
{ align: 'left' as const, header: 'Models', key: 'models' },
|
||||
{ align: 'right' as const, header: 'Input', key: 'input' },
|
||||
{ align: 'right' as const, header: 'Output', key: 'output' },
|
||||
{ align: 'right' as const, header: ['Total', 'Tokens'], key: 'total' },
|
||||
{ align: 'right' as const, header: 'Requests', key: 'requests' },
|
||||
{ align: 'right' as const, header: ['Cost', '(USD)'], key: 'cost' },
|
||||
];
|
||||
|
||||
// Totals
|
||||
let sumInput = 0;
|
||||
let sumOutput = 0;
|
||||
let sumTotal = 0;
|
||||
let sumRequests = 0;
|
||||
let sumCost = 0;
|
||||
|
||||
const rows: BoxTableRow[] = activeLogs.map((log: any) => {
|
||||
const records: any[] = log.records || [];
|
||||
|
||||
// Aggregate tokens
|
||||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
for (const r of records) {
|
||||
inputTokens += r.totalInputTokens || 0;
|
||||
outputTokens += r.totalOutputTokens || 0;
|
||||
}
|
||||
const totalTokens = log.totalTokens || inputTokens + outputTokens;
|
||||
const cost = log.totalSpend || 0;
|
||||
const requests = log.totalRequests || 0;
|
||||
|
||||
sumInput += inputTokens;
|
||||
sumOutput += outputTokens;
|
||||
sumTotal += totalTokens;
|
||||
sumRequests += requests;
|
||||
sumCost += cost;
|
||||
|
||||
// Unique models
|
||||
const modelSet = new Set<string>();
|
||||
for (const r of records) {
|
||||
if (r.model) modelSet.add(r.model);
|
||||
}
|
||||
const modelList = [...modelSet].sort().map((m) => `- ${m}`);
|
||||
|
||||
return {
|
||||
cost: formatCost(cost),
|
||||
date: log.day || '',
|
||||
input: formatNumber(inputTokens),
|
||||
models: modelList.length > 0 ? modelList : ['-'],
|
||||
output: formatNumber(outputTokens),
|
||||
requests: formatNumber(requests),
|
||||
total: formatNumber(totalTokens),
|
||||
};
|
||||
});
|
||||
|
||||
// Total row
|
||||
rows.push({
|
||||
cost: pc.bold(formatCost(sumCost)),
|
||||
date: pc.bold('Total'),
|
||||
input: pc.bold(formatNumber(sumInput)),
|
||||
models: '',
|
||||
output: pc.bold(formatNumber(sumOutput)),
|
||||
requests: pc.bold(formatNumber(sumRequests)),
|
||||
total: pc.bold(formatNumber(sumTotal)),
|
||||
});
|
||||
|
||||
const monthLabel = options.month || new Date().toISOString().slice(0, 7);
|
||||
const mode = options.daily ? 'Daily' : 'Monthly';
|
||||
printBoxTable(columns, rows, `LobeHub Token Usage Report - ${mode} (${monthLabel})`);
|
||||
|
||||
// Calendar heatmap - fetch past 12 months
|
||||
const now = new Date();
|
||||
const rangeStart = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate() + 1);
|
||||
let yearLogs: any[];
|
||||
|
||||
try {
|
||||
// Try single-request endpoint first
|
||||
yearLogs = await client.usage.findAndGroupByDateRange.query({
|
||||
endAt: now.toISOString().slice(0, 10),
|
||||
startAt: rangeStart.toISOString().slice(0, 10),
|
||||
});
|
||||
} catch {
|
||||
// Fallback: fetch each month concurrently
|
||||
const monthKeys: string[] = [];
|
||||
for (let i = 11; i >= 0; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
monthKeys.push(d.toISOString().slice(0, 7));
|
||||
}
|
||||
const results = await Promise.all(
|
||||
monthKeys.map((mo) => client.usage.findAndGroupByDay.query({ mo })),
|
||||
);
|
||||
yearLogs = results.flat();
|
||||
}
|
||||
|
||||
const calendarData = (Array.isArray(yearLogs) ? yearLogs : [])
|
||||
.filter((log: any) => log.day)
|
||||
.map((log: any) => ({
|
||||
day: log.day,
|
||||
value: log.totalTokens || 0,
|
||||
}));
|
||||
|
||||
const yearTotal = calendarData.reduce((acc: number, d: any) => acc + d.value, 0);
|
||||
|
||||
printCalendarHeatmap(calendarData, {
|
||||
label: `Past 12 months: ${formatNumber(yearTotal)} tokens`,
|
||||
title: 'Activity (past 12 months)',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { registerCronCommand } from './cron';
|
||||
|
||||
const { mockTrpcClient } = vi.hoisted(() => ({
|
||||
mockTrpcClient: {
|
||||
agentCronJob: {
|
||||
batchUpdateStatus: { mutate: vi.fn() },
|
||||
create: { mutate: vi.fn() },
|
||||
delete: { mutate: vi.fn() },
|
||||
findById: { query: vi.fn() },
|
||||
getStats: { query: vi.fn() },
|
||||
list: { query: vi.fn() },
|
||||
resetExecutions: { mutate: vi.fn() },
|
||||
update: { mutate: vi.fn() },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const { getTrpcClient: mockGetTrpcClient } = vi.hoisted(() => ({
|
||||
getTrpcClient: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../api/client', () => ({ getTrpcClient: mockGetTrpcClient }));
|
||||
vi.mock('../utils/logger', () => ({
|
||||
log: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() },
|
||||
setVerbose: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('cron command', () => {
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
|
||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
mockGetTrpcClient.mockResolvedValue(mockTrpcClient);
|
||||
for (const method of Object.values(mockTrpcClient.agentCronJob)) {
|
||||
for (const fn of Object.values(method)) {
|
||||
(fn as ReturnType<typeof vi.fn>).mockReset();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
exitSpy.mockRestore();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
function createProgram() {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerCronCommand(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
describe('list', () => {
|
||||
it('should list cron jobs', async () => {
|
||||
mockTrpcClient.agentCronJob.list.query.mockResolvedValue({
|
||||
data: [{ enabled: true, id: 'c1', name: 'Test Job', schedule: '* * * * *' }],
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'cron', 'list']);
|
||||
|
||||
expect(mockTrpcClient.agentCronJob.list.query).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should filter by agent-id', async () => {
|
||||
mockTrpcClient.agentCronJob.list.query.mockResolvedValue({ data: [] });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'cron', 'list', '--agent-id', 'a1']);
|
||||
|
||||
expect(mockTrpcClient.agentCronJob.list.query).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: 'a1' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('view', () => {
|
||||
it('should view cron job details', async () => {
|
||||
mockTrpcClient.agentCronJob.findById.query.mockResolvedValue({
|
||||
data: { enabled: true, id: 'c1', name: 'Test', schedule: '* * * * *' },
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'cron', 'view', 'c1']);
|
||||
|
||||
expect(mockTrpcClient.agentCronJob.findById.query).toHaveBeenCalledWith({ id: 'c1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a cron job', async () => {
|
||||
mockTrpcClient.agentCronJob.create.mutate.mockResolvedValue({ data: { id: 'c1' } });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'cron',
|
||||
'create',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'-s',
|
||||
'* * * * *',
|
||||
'-n',
|
||||
'My Job',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentCronJob.create.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: 'a1', name: 'My Job', schedule: '* * * * *' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete a cron job', async () => {
|
||||
mockTrpcClient.agentCronJob.delete.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'cron', 'delete', 'c1', '--yes']);
|
||||
|
||||
expect(mockTrpcClient.agentCronJob.delete.mutate).toHaveBeenCalledWith({ id: 'c1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggle', () => {
|
||||
it('should batch enable cron jobs', async () => {
|
||||
mockTrpcClient.agentCronJob.batchUpdateStatus.mutate.mockResolvedValue({
|
||||
data: { updatedCount: 2 },
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'cron', 'toggle', 'c1', 'c2', '--enable']);
|
||||
|
||||
expect(mockTrpcClient.agentCronJob.batchUpdateStatus.mutate).toHaveBeenCalledWith({
|
||||
enabled: true,
|
||||
ids: ['c1', 'c2'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('should reset execution count', async () => {
|
||||
mockTrpcClient.agentCronJob.resetExecutions.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'cron', 'reset', 'c1', '--max', '100']);
|
||||
|
||||
expect(mockTrpcClient.agentCronJob.resetExecutions.mutate).toHaveBeenCalledWith({
|
||||
id: 'c1',
|
||||
newMaxExecutions: 100,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('stats', () => {
|
||||
it('should get stats', async () => {
|
||||
mockTrpcClient.agentCronJob.getStats.query.mockResolvedValue({
|
||||
data: { totalJobs: 5, totalExecutions: 100 },
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'cron', 'stats']);
|
||||
|
||||
expect(mockTrpcClient.agentCronJob.getStats.query).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,271 @@
|
||||
import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../api/client';
|
||||
import { confirm, outputJson, printTable, timeAgo, truncate } from '../utils/format';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
export function registerCronCommand(program: Command) {
|
||||
const cron = program.command('cron').description('Manage agent cron jobs');
|
||||
|
||||
// ── list ──────────────────────────────────────────────
|
||||
|
||||
cron
|
||||
.command('list')
|
||||
.description('List cron jobs')
|
||||
.option('--agent-id <id>', 'Filter by agent ID')
|
||||
.option('--enabled', 'Only show enabled jobs')
|
||||
.option('--disabled', 'Only show disabled jobs')
|
||||
.option('-L, --limit <n>', 'Page size', '20')
|
||||
.option('--offset <n>', 'Offset', '0')
|
||||
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
|
||||
.action(
|
||||
async (options: {
|
||||
agentId?: string;
|
||||
disabled?: boolean;
|
||||
enabled?: boolean;
|
||||
json?: string | boolean;
|
||||
limit?: string;
|
||||
offset?: string;
|
||||
}) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
const input: Record<string, any> = {};
|
||||
if (options.agentId) input.agentId = options.agentId;
|
||||
if (options.enabled) input.enabled = true;
|
||||
if (options.disabled) input.enabled = false;
|
||||
if (options.limit) input.limit = Number.parseInt(options.limit, 10);
|
||||
if (options.offset) input.offset = Number.parseInt(options.offset, 10);
|
||||
|
||||
const result = await client.agentCronJob.list.query(input as any);
|
||||
const items = (result as any).data ?? [];
|
||||
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(items, fields);
|
||||
return;
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
console.log('No cron jobs found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = items.map((j: any) => [
|
||||
j.id || '',
|
||||
truncate(j.name || '', 30),
|
||||
j.schedule || '',
|
||||
j.enabled ? pc.green('enabled') : pc.dim('disabled'),
|
||||
`${j.executionCount ?? 0}/${j.maxExecutions ?? '∞'}`,
|
||||
j.updatedAt ? timeAgo(j.updatedAt) : '',
|
||||
]);
|
||||
|
||||
printTable(rows, ['ID', 'NAME', 'SCHEDULE', 'STATUS', 'EXECUTIONS', 'UPDATED']);
|
||||
},
|
||||
);
|
||||
|
||||
// ── view ──────────────────────────────────────────────
|
||||
|
||||
cron
|
||||
.command('view <id>')
|
||||
.description('View cron job details')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(async (id: string, options: { json?: string | boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.agentCronJob.findById.query({ id });
|
||||
const job = (result as any).data;
|
||||
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(job, fields);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!job) {
|
||||
log.error('Cron job not found.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`${pc.bold('ID:')} ${job.id}`);
|
||||
console.log(`${pc.bold('Name:')} ${job.name || ''}`);
|
||||
console.log(`${pc.bold('Agent ID:')} ${job.agentId || ''}`);
|
||||
console.log(`${pc.bold('Schedule:')} ${job.schedule || ''}`);
|
||||
console.log(
|
||||
`${pc.bold('Status:')} ${job.enabled ? pc.green('enabled') : pc.dim('disabled')}`,
|
||||
);
|
||||
console.log(
|
||||
`${pc.bold('Executions:')} ${job.executionCount ?? 0}/${job.maxExecutions ?? '∞'}`,
|
||||
);
|
||||
if (job.prompt) console.log(`${pc.bold('Prompt:')} ${truncate(job.prompt, 80)}`);
|
||||
if (job.createdAt) console.log(`${pc.bold('Created:')} ${timeAgo(job.createdAt)}`);
|
||||
if (job.updatedAt) console.log(`${pc.bold('Updated:')} ${timeAgo(job.updatedAt)}`);
|
||||
});
|
||||
|
||||
// ── create ────────────────────────────────────────────
|
||||
|
||||
cron
|
||||
.command('create')
|
||||
.description('Create a cron job')
|
||||
.requiredOption('--agent-id <id>', 'Agent ID')
|
||||
.requiredOption('-s, --schedule <cron>', 'Cron schedule expression')
|
||||
.option('-n, --name <name>', 'Job name')
|
||||
.option('-p, --prompt <prompt>', 'Prompt text')
|
||||
.option('--max-executions <n>', 'Maximum number of executions')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(
|
||||
async (options: {
|
||||
agentId: string;
|
||||
json?: boolean;
|
||||
maxExecutions?: string;
|
||||
name?: string;
|
||||
prompt?: string;
|
||||
schedule: string;
|
||||
}) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
const input: Record<string, any> = {
|
||||
agentId: options.agentId,
|
||||
schedule: options.schedule,
|
||||
};
|
||||
if (options.name) input.name = options.name;
|
||||
if (options.prompt) input.prompt = options.prompt;
|
||||
if (options.maxExecutions) input.maxExecutions = Number.parseInt(options.maxExecutions, 10);
|
||||
|
||||
const result = await client.agentCronJob.create.mutate(input as any);
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (result as any).data;
|
||||
console.log(`${pc.green('✓')} Created cron job ${pc.bold(data?.id || '')}`);
|
||||
},
|
||||
);
|
||||
|
||||
// ── edit ───────────────────────────────────────────────
|
||||
|
||||
cron
|
||||
.command('edit <id>')
|
||||
.description('Update a cron job')
|
||||
.option('-n, --name <name>', 'Job name')
|
||||
.option('-s, --schedule <cron>', 'Cron schedule expression')
|
||||
.option('-p, --prompt <prompt>', 'Prompt text')
|
||||
.option('--max-executions <n>', 'Maximum number of executions')
|
||||
.option('--enable', 'Enable the job')
|
||||
.option('--disable', 'Disable the job')
|
||||
.action(
|
||||
async (
|
||||
id: string,
|
||||
options: {
|
||||
disable?: boolean;
|
||||
enable?: boolean;
|
||||
maxExecutions?: string;
|
||||
name?: string;
|
||||
prompt?: string;
|
||||
schedule?: string;
|
||||
},
|
||||
) => {
|
||||
const data: Record<string, any> = {};
|
||||
if (options.name) data.name = options.name;
|
||||
if (options.schedule) data.schedule = options.schedule;
|
||||
if (options.prompt) data.prompt = options.prompt;
|
||||
if (options.maxExecutions) data.maxExecutions = Number.parseInt(options.maxExecutions, 10);
|
||||
if (options.enable) data.enabled = true;
|
||||
if (options.disable) data.enabled = false;
|
||||
|
||||
if (Object.keys(data).length === 0) {
|
||||
log.error(
|
||||
'No changes specified. Use --name, --schedule, --prompt, --enable, or --disable.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
await client.agentCronJob.update.mutate({ data, id } as any);
|
||||
console.log(`${pc.green('✓')} Updated cron job ${pc.bold(id)}`);
|
||||
},
|
||||
);
|
||||
|
||||
// ── delete ────────────────────────────────────────────
|
||||
|
||||
cron
|
||||
.command('delete <id>')
|
||||
.description('Delete a cron job')
|
||||
.option('--yes', 'Skip confirmation prompt')
|
||||
.action(async (id: string, options: { yes?: boolean }) => {
|
||||
if (!options.yes) {
|
||||
const confirmed = await confirm('Are you sure you want to delete this cron job?');
|
||||
if (!confirmed) {
|
||||
console.log('Cancelled.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
await client.agentCronJob.delete.mutate({ id });
|
||||
console.log(`${pc.green('✓')} Deleted cron job ${pc.bold(id)}`);
|
||||
});
|
||||
|
||||
// ── toggle ────────────────────────────────────────────
|
||||
|
||||
cron
|
||||
.command('toggle <ids...>')
|
||||
.description('Batch enable or disable cron jobs')
|
||||
.option('--enable', 'Enable the jobs')
|
||||
.option('--disable', 'Disable the jobs')
|
||||
.action(async (ids: string[], options: { disable?: boolean; enable?: boolean }) => {
|
||||
if (!options.enable && !options.disable) {
|
||||
log.error('Specify --enable or --disable.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const enabled = !!options.enable;
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.agentCronJob.batchUpdateStatus.mutate({ enabled, ids });
|
||||
const count = (result as any).data?.updatedCount ?? ids.length;
|
||||
console.log(`${pc.green('✓')} ${enabled ? 'Enabled' : 'Disabled'} ${count} cron job(s)`);
|
||||
});
|
||||
|
||||
// ── reset ─────────────────────────────────────────────
|
||||
|
||||
cron
|
||||
.command('reset <id>')
|
||||
.description('Reset execution count for a cron job')
|
||||
.option('--max <n>', 'Set new max executions')
|
||||
.action(async (id: string, options: { max?: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
const input: Record<string, any> = { id };
|
||||
if (options.max) input.newMaxExecutions = Number.parseInt(options.max, 10);
|
||||
|
||||
await client.agentCronJob.resetExecutions.mutate(input as any);
|
||||
console.log(`${pc.green('✓')} Reset execution count for ${pc.bold(id)}`);
|
||||
});
|
||||
|
||||
// ── stats ─────────────────────────────────────────────
|
||||
|
||||
cron
|
||||
.command('stats')
|
||||
.description('Get cron job execution statistics')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(async (options: { json?: boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.agentCronJob.getStats.query();
|
||||
const stats = (result as any).data;
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(stats, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stats) {
|
||||
console.log('No statistics available.');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(stats as Record<string, any>)) {
|
||||
console.log(`${pc.bold(key + ':')} ${value}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../api/client';
|
||||
import { outputJson, printTable, timeAgo } from '../utils/format';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
export function registerDeviceCommand(program: Command) {
|
||||
const device = program.command('device').description('Manage connected devices');
|
||||
|
||||
// ── list ──────────────────────────────────────────────
|
||||
|
||||
device
|
||||
.command('list')
|
||||
.description('List all online devices')
|
||||
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
|
||||
.action(async (options: { json?: string | boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const devices = await client.device.listDevices.query();
|
||||
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(devices, fields);
|
||||
return;
|
||||
}
|
||||
|
||||
if (devices.length === 0) {
|
||||
console.log('No online devices found.');
|
||||
console.log(pc.dim("Use 'lh connect' to connect this device."));
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = devices.map((d: any) => [
|
||||
d.deviceId || '',
|
||||
d.hostname || '',
|
||||
d.platform || '',
|
||||
d.online ? pc.green('online') : pc.dim('offline'),
|
||||
d.lastSeen ? timeAgo(d.lastSeen) : '',
|
||||
]);
|
||||
|
||||
printTable(rows, ['DEVICE ID', 'HOSTNAME', 'PLATFORM', 'STATUS', 'CONNECTED']);
|
||||
});
|
||||
|
||||
// ── info ──────────────────────────────────────────────
|
||||
|
||||
device
|
||||
.command('info <deviceId>')
|
||||
.description('Show system info of a specific device')
|
||||
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
|
||||
.action(async (deviceId: string, options: { json?: string | boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const info = await client.device.getDeviceSystemInfo.query({ deviceId });
|
||||
|
||||
if (!info) {
|
||||
log.error(`Device "${deviceId}" is not reachable or does not exist.`);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(info, fields);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(pc.bold('Device System Info'));
|
||||
console.log(` Architecture : ${info.arch}`);
|
||||
console.log(` Working Directory : ${info.workingDirectory}`);
|
||||
console.log(` Home : ${info.homePath}`);
|
||||
console.log(` Desktop : ${info.desktopPath}`);
|
||||
console.log(` Documents : ${info.documentsPath}`);
|
||||
console.log(` Downloads : ${info.downloadsPath}`);
|
||||
console.log(` Music : ${info.musicPath}`);
|
||||
console.log(` Pictures : ${info.picturesPath}`);
|
||||
console.log(` Videos : ${info.videosPath}`);
|
||||
});
|
||||
|
||||
// ── status ────────────────────────────────────────────
|
||||
|
||||
device
|
||||
.command('status')
|
||||
.description('Show device connection overview')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(async (options: { json?: boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const status = await client.device.status.query();
|
||||
|
||||
if (options.json) {
|
||||
outputJson(status);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(pc.bold('Device Status'));
|
||||
console.log(` Online : ${status.online ? pc.green('yes') : pc.dim('no')}`);
|
||||
console.log(` Devices : ${status.deviceCount}`);
|
||||
});
|
||||
}
|
||||
@@ -8,12 +8,19 @@ const { mockTrpcClient } = vi.hoisted(() => ({
|
||||
mockTrpcClient: {
|
||||
document: {
|
||||
createDocument: { mutate: vi.fn() },
|
||||
createDocuments: { mutate: vi.fn() },
|
||||
deleteDocument: { mutate: vi.fn() },
|
||||
deleteDocuments: { mutate: vi.fn() },
|
||||
getDocumentById: { query: vi.fn() },
|
||||
parseDocument: { mutate: vi.fn() },
|
||||
parseFileContent: { mutate: vi.fn() },
|
||||
queryDocuments: { query: vi.fn() },
|
||||
updateDocument: { mutate: vi.fn() },
|
||||
},
|
||||
notebook: {
|
||||
createDocument: { mutate: vi.fn() },
|
||||
listDocuments: { query: vi.fn() },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -44,16 +51,23 @@ describe('doc command', () => {
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
function resetMocks(obj: Record<string, any>) {
|
||||
for (const val of Object.values(obj)) {
|
||||
if (typeof val === 'object' && val !== null) {
|
||||
if (typeof val.mockReset === 'function') {
|
||||
val.mockReset();
|
||||
} else {
|
||||
resetMocks(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
|
||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
mockGetTrpcClient.mockResolvedValue(mockTrpcClient);
|
||||
// Reset all document mock return values
|
||||
for (const method of Object.values(mockTrpcClient.document)) {
|
||||
for (const fn of Object.values(method)) {
|
||||
(fn as ReturnType<typeof vi.fn>).mockReset();
|
||||
}
|
||||
}
|
||||
resetMocks(mockTrpcClient);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -85,10 +99,9 @@ describe('doc command', () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'doc', 'list']);
|
||||
|
||||
expect(mockTrpcClient.document.queryDocuments.query).toHaveBeenCalledWith({
|
||||
fileTypes: undefined,
|
||||
pageSize: 30,
|
||||
});
|
||||
expect(mockTrpcClient.document.queryDocuments.query).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ pageSize: 30 }),
|
||||
);
|
||||
// Header + 2 rows
|
||||
expect(consoleSpy).toHaveBeenCalledTimes(3);
|
||||
expect(consoleSpy.mock.calls[0][0]).toContain('ID');
|
||||
@@ -122,10 +135,20 @@ describe('doc command', () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'doc', 'list', '--file-type', 'md']);
|
||||
|
||||
expect(mockTrpcClient.document.queryDocuments.query).toHaveBeenCalledWith({
|
||||
fileTypes: ['md'],
|
||||
pageSize: 30,
|
||||
});
|
||||
expect(mockTrpcClient.document.queryDocuments.query).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ fileTypes: ['md'] }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter by source type', async () => {
|
||||
mockTrpcClient.document.queryDocuments.query.mockResolvedValue([]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'doc', 'list', '--source-type', 'topic']);
|
||||
|
||||
expect(mockTrpcClient.document.queryDocuments.query).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sourceTypes: ['topic'] }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show message when no documents found', async () => {
|
||||
@@ -143,10 +166,9 @@ describe('doc command', () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'doc', 'list', '-L', '10']);
|
||||
|
||||
expect(mockTrpcClient.document.queryDocuments.query).toHaveBeenCalledWith({
|
||||
fileTypes: undefined,
|
||||
pageSize: 10,
|
||||
});
|
||||
expect(mockTrpcClient.document.queryDocuments.query).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ pageSize: 10 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -166,11 +188,26 @@ describe('doc command', () => {
|
||||
await program.parseAsync(['node', 'test', 'doc', 'view', 'doc1']);
|
||||
|
||||
expect(mockTrpcClient.document.getDocumentById.query).toHaveBeenCalledWith({ id: 'doc1' });
|
||||
// Title, meta, blank line, content = 4 calls
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Test Doc'));
|
||||
expect(consoleSpy).toHaveBeenCalledWith('# Hello World');
|
||||
});
|
||||
|
||||
it('should show knowledge base ID in meta', async () => {
|
||||
mockTrpcClient.document.getDocumentById.query.mockResolvedValue({
|
||||
content: 'test',
|
||||
fileType: 'md',
|
||||
id: 'doc1',
|
||||
knowledgeBaseId: 'kb_123',
|
||||
title: 'KB Doc',
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'doc', 'view', 'doc1']);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('KB: kb_123'));
|
||||
});
|
||||
|
||||
it('should output JSON when --json flag is used', async () => {
|
||||
const doc = { content: 'test', id: 'doc1', title: 'Test' };
|
||||
mockTrpcClient.document.getDocumentById.query.mockResolvedValue(doc);
|
||||
@@ -271,6 +308,107 @@ describe('doc command', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should support --kb flag for knowledge base association', async () => {
|
||||
mockTrpcClient.document.createDocument.mutate.mockResolvedValue({ id: 'new-doc' });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'doc',
|
||||
'create',
|
||||
'--title',
|
||||
'KB Doc',
|
||||
'--kb',
|
||||
'kb_123',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.document.createDocument.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
knowledgeBaseId: 'kb_123',
|
||||
title: 'KB Doc',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should support --file-type flag', async () => {
|
||||
mockTrpcClient.document.createDocument.mutate.mockResolvedValue({ id: 'new-doc' });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'doc',
|
||||
'create',
|
||||
'--title',
|
||||
'Folder',
|
||||
'--file-type',
|
||||
'custom/folder',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.document.createDocument.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
fileType: 'custom/folder',
|
||||
title: 'Folder',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── batch-create ───────────────────────────────────────
|
||||
|
||||
describe('batch-create', () => {
|
||||
it('should batch create documents from JSON file', async () => {
|
||||
const docs = [
|
||||
{ content: 'content1', title: 'Doc 1' },
|
||||
{ content: 'content2', title: 'Doc 2' },
|
||||
];
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
vi.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(docs));
|
||||
mockTrpcClient.document.createDocuments.mutate.mockResolvedValue([
|
||||
{ id: 'doc1', title: 'Doc 1' },
|
||||
{ id: 'doc2', title: 'Doc 2' },
|
||||
]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'doc', 'batch-create', 'docs.json']);
|
||||
|
||||
expect(mockTrpcClient.document.createDocuments.mutate).toHaveBeenCalledWith({
|
||||
documents: expect.arrayContaining([
|
||||
expect.objectContaining({ content: 'content1', title: 'Doc 1' }),
|
||||
expect.objectContaining({ content: 'content2', title: 'Doc 2' }),
|
||||
]),
|
||||
});
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Created 2'));
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should error when file not found', async () => {
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(false);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'doc', 'batch-create', 'missing.json']);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('not found'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should error when JSON is not an array', async () => {
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
vi.spyOn(fs, 'readFileSync').mockReturnValue('{"not": "array"}');
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'doc', 'batch-create', 'bad.json']);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('non-empty array'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
// ── edit ──────────────────────────────────────────────
|
||||
@@ -305,6 +443,28 @@ describe('doc command', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should update file type', async () => {
|
||||
mockTrpcClient.document.updateDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'doc',
|
||||
'edit',
|
||||
'doc1',
|
||||
'--file-type',
|
||||
'custom/folder',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.document.updateDocument.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
fileType: 'custom/folder',
|
||||
id: 'doc1',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should exit with error when no changes specified', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'doc', 'edit', 'doc1']);
|
||||
@@ -339,4 +499,149 @@ describe('doc command', () => {
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Deleted 2'));
|
||||
});
|
||||
});
|
||||
|
||||
// ── parse ─────────────────────────────────────────────
|
||||
|
||||
describe('parse', () => {
|
||||
it('should parse a file without pages by default', async () => {
|
||||
mockTrpcClient.document.parseDocument.mutate.mockResolvedValue({
|
||||
content: 'Parsed content',
|
||||
title: 'Parsed Doc',
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'doc', 'parse', 'file_123']);
|
||||
|
||||
expect(mockTrpcClient.document.parseDocument.mutate).toHaveBeenCalledWith({
|
||||
id: 'file_123',
|
||||
});
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Parsed file'));
|
||||
});
|
||||
|
||||
it('should use parseFileContent with --with-pages', async () => {
|
||||
mockTrpcClient.document.parseFileContent.mutate.mockResolvedValue({
|
||||
content: 'Parsed with pages',
|
||||
title: 'Paged Doc',
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'doc', 'parse', 'file_123', '--with-pages']);
|
||||
|
||||
expect(mockTrpcClient.document.parseFileContent.mutate).toHaveBeenCalledWith({
|
||||
id: 'file_123',
|
||||
});
|
||||
});
|
||||
|
||||
it('should output JSON with --json flag', async () => {
|
||||
const result = { content: 'test', title: 'Doc' };
|
||||
mockTrpcClient.document.parseDocument.mutate.mockResolvedValue(result);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'doc', 'parse', 'file_123', '--json']);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(result, null, 2));
|
||||
});
|
||||
});
|
||||
|
||||
// ── link-topic ────────────────────────────────────────
|
||||
|
||||
describe('link-topic', () => {
|
||||
it('should link a document to a topic', async () => {
|
||||
mockTrpcClient.document.getDocumentById.query.mockResolvedValue({
|
||||
content: 'doc content',
|
||||
description: 'desc',
|
||||
id: 'doc1',
|
||||
title: 'My Doc',
|
||||
});
|
||||
mockTrpcClient.notebook.createDocument.mutate.mockResolvedValue({ id: 'new-doc' });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'doc', 'link-topic', 'doc1', 'topic_123']);
|
||||
|
||||
expect(mockTrpcClient.notebook.createDocument.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
content: 'doc content',
|
||||
title: 'My Doc',
|
||||
topicId: 'topic_123',
|
||||
}),
|
||||
);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Linked'));
|
||||
});
|
||||
|
||||
it('should error when document not found', async () => {
|
||||
mockTrpcClient.document.getDocumentById.query.mockResolvedValue(null);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'doc', 'link-topic', 'bad-id', 'topic_123']);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('not found'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── topic-docs ────────────────────────────────────────
|
||||
|
||||
describe('topic-docs', () => {
|
||||
it('should list documents for a topic', async () => {
|
||||
mockTrpcClient.notebook.listDocuments.query.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
fileType: 'markdown',
|
||||
id: 'doc1',
|
||||
title: 'Note 1',
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{ fileType: 'article', id: 'doc2', title: 'Note 2', updatedAt: new Date().toISOString() },
|
||||
],
|
||||
total: 2,
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'doc', 'topic-docs', 'topic_123']);
|
||||
|
||||
expect(mockTrpcClient.notebook.listDocuments.query).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ topicId: 'topic_123' }),
|
||||
);
|
||||
// Header + 2 rows
|
||||
expect(consoleSpy).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should filter by --type', async () => {
|
||||
mockTrpcClient.notebook.listDocuments.query.mockResolvedValue({ data: [], total: 0 });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'doc',
|
||||
'topic-docs',
|
||||
'topic_123',
|
||||
'--type',
|
||||
'article',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.notebook.listDocuments.query).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ topicId: 'topic_123', type: 'article' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should output JSON with --json flag', async () => {
|
||||
const docs = [{ id: 'doc1', title: 'Note' }];
|
||||
mockTrpcClient.notebook.listDocuments.query.mockResolvedValue({ data: docs, total: 1 });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'doc', 'topic-docs', 'topic_123', '--json']);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(docs, null, 2));
|
||||
});
|
||||
|
||||
it('should show message when no documents found', async () => {
|
||||
mockTrpcClient.notebook.listDocuments.query.mockResolvedValue({ data: [], total: 0 });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'doc', 'topic-docs', 'topic_123']);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith('No documents found for this topic.');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+197
-27
@@ -32,36 +32,47 @@ export function registerDocCommand(program: Command) {
|
||||
.description('List documents')
|
||||
.option('-L, --limit <n>', 'Maximum number of items to fetch', '30')
|
||||
.option('--file-type <type>', 'Filter by file type')
|
||||
.option('--source-type <type>', 'Filter by source type (file, web, api, topic)')
|
||||
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
|
||||
.action(async (options: { fileType?: string; json?: string | boolean; limit?: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
const pageSize = Number.parseInt(options.limit || '30', 10);
|
||||
.action(
|
||||
async (options: {
|
||||
fileType?: string;
|
||||
json?: string | boolean;
|
||||
limit?: string;
|
||||
sourceType?: string;
|
||||
}) => {
|
||||
const client = await getTrpcClient();
|
||||
const pageSize = Number.parseInt(options.limit || '30', 10);
|
||||
|
||||
const query: { fileTypes?: string[]; pageSize: number } = { pageSize };
|
||||
if (options.fileType) query.fileTypes = [options.fileType];
|
||||
const result = await client.document.queryDocuments.query(query);
|
||||
const docs = Array.isArray(result) ? result : ((result as any).items ?? []);
|
||||
const query: { fileTypes?: string[]; pageSize: number; sourceTypes?: string[] } = {
|
||||
pageSize,
|
||||
};
|
||||
if (options.fileType) query.fileTypes = [options.fileType];
|
||||
if (options.sourceType) query.sourceTypes = [options.sourceType];
|
||||
const result = await client.document.queryDocuments.query(query);
|
||||
const docs = Array.isArray(result) ? result : ((result as any).items ?? []);
|
||||
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(docs, fields);
|
||||
return;
|
||||
}
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(docs, fields);
|
||||
return;
|
||||
}
|
||||
|
||||
if (docs.length === 0) {
|
||||
console.log('No documents found.');
|
||||
return;
|
||||
}
|
||||
if (docs.length === 0) {
|
||||
console.log('No documents found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = docs.map((d: any) => [
|
||||
d.id,
|
||||
truncate(d.title || d.filename || 'Untitled', 120),
|
||||
d.fileType || '',
|
||||
d.updatedAt ? timeAgo(d.updatedAt) : '',
|
||||
]);
|
||||
const rows = docs.map((d: any) => [
|
||||
d.id,
|
||||
truncate(d.title || d.filename || 'Untitled', 120),
|
||||
d.fileType || '',
|
||||
d.updatedAt ? timeAgo(d.updatedAt) : '',
|
||||
]);
|
||||
|
||||
printTable(rows, ['ID', 'TITLE', 'TYPE', 'UPDATED']);
|
||||
});
|
||||
printTable(rows, ['ID', 'TITLE', 'TYPE', 'UPDATED']);
|
||||
},
|
||||
);
|
||||
|
||||
// ── view ──────────────────────────────────────────────
|
||||
|
||||
@@ -89,6 +100,7 @@ export function registerDocCommand(program: Command) {
|
||||
console.log(pc.bold(document.title || 'Untitled'));
|
||||
const meta: string[] = [];
|
||||
if (document.fileType) meta.push(document.fileType);
|
||||
if ((document as any).knowledgeBaseId) meta.push(`KB: ${(document as any).knowledgeBaseId}`);
|
||||
if (document.updatedAt) meta.push(`Updated ${timeAgo(document.updatedAt)}`);
|
||||
if (meta.length > 0) console.log(pc.dim(meta.join(' · ')));
|
||||
console.log();
|
||||
@@ -110,10 +122,14 @@ export function registerDocCommand(program: Command) {
|
||||
.option('-F, --body-file <path>', 'Read content from file')
|
||||
.option('--parent <id>', 'Parent document or folder ID')
|
||||
.option('--slug <slug>', 'Custom slug')
|
||||
.option('--kb <id>', 'Knowledge base ID to associate with')
|
||||
.option('--file-type <type>', 'File type (e.g. custom/document, custom/folder)')
|
||||
.action(
|
||||
async (options: {
|
||||
body?: string;
|
||||
bodyFile?: string;
|
||||
fileType?: string;
|
||||
kb?: string;
|
||||
parent?: string;
|
||||
slug?: string;
|
||||
title: string;
|
||||
@@ -124,6 +140,8 @@ export function registerDocCommand(program: Command) {
|
||||
const result = await client.document.createDocument.mutate({
|
||||
content,
|
||||
editorData: JSON.stringify({ content: content || '', type: 'doc' }),
|
||||
fileType: options.fileType,
|
||||
knowledgeBaseId: options.kb,
|
||||
parentId: options.parent,
|
||||
slug: options.slug,
|
||||
title: options.title,
|
||||
@@ -133,6 +151,54 @@ export function registerDocCommand(program: Command) {
|
||||
},
|
||||
);
|
||||
|
||||
// ── batch-create ───────────────────────────────────────
|
||||
|
||||
doc
|
||||
.command('batch-create <file>')
|
||||
.description('Batch create documents from a JSON file')
|
||||
.action(async (file: string) => {
|
||||
if (!fs.existsSync(file)) {
|
||||
log.error(`File not found: ${file}`);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
let documents: any[];
|
||||
try {
|
||||
const raw = fs.readFileSync(file, 'utf8');
|
||||
documents = JSON.parse(raw);
|
||||
} catch {
|
||||
log.error('Failed to parse JSON file. Expected an array of document objects.');
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(documents) || documents.length === 0) {
|
||||
log.error('JSON file must contain a non-empty array of document objects.');
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
|
||||
const items = documents.map((d) => ({
|
||||
content: d.content,
|
||||
editorData: JSON.stringify({ content: d.content || '', type: 'doc' }),
|
||||
fileType: d.fileType,
|
||||
knowledgeBaseId: d.knowledgeBaseId,
|
||||
parentId: d.parentId,
|
||||
slug: d.slug,
|
||||
title: d.title,
|
||||
}));
|
||||
|
||||
const result = await client.document.createDocuments.mutate({ documents: items });
|
||||
const created = Array.isArray(result) ? result : [result];
|
||||
console.log(`${pc.green('✓')} Created ${created.length} document(s)`);
|
||||
for (const doc of created) {
|
||||
console.log(` ${pc.dim('•')} ${doc.id} — ${doc.title || 'Untitled'}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── edit ──────────────────────────────────────────────
|
||||
|
||||
doc
|
||||
@@ -142,15 +208,24 @@ export function registerDocCommand(program: Command) {
|
||||
.option('-b, --body <content>', 'New content')
|
||||
.option('-F, --body-file <path>', 'Read new content from file')
|
||||
.option('--parent <id>', 'Move to parent document (empty string for root)')
|
||||
.option('--file-type <type>', 'Change file type')
|
||||
.action(
|
||||
async (
|
||||
id: string,
|
||||
options: { body?: string; bodyFile?: string; parent?: string; title?: string },
|
||||
options: {
|
||||
body?: string;
|
||||
bodyFile?: string;
|
||||
fileType?: string;
|
||||
parent?: string;
|
||||
title?: string;
|
||||
},
|
||||
) => {
|
||||
const content = readBodyContent(options);
|
||||
|
||||
if (!options.title && !content && options.parent === undefined) {
|
||||
log.error('No changes specified. Use --title, --body, --body-file, or --parent.');
|
||||
if (!options.title && !content && options.parent === undefined && !options.fileType) {
|
||||
log.error(
|
||||
'No changes specified. Use --title, --body, --body-file, --parent, or --file-type.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -165,6 +240,7 @@ export function registerDocCommand(program: Command) {
|
||||
if (options.parent !== undefined) {
|
||||
params.parentId = options.parent || null;
|
||||
}
|
||||
if (options.fileType) params.fileType = options.fileType;
|
||||
|
||||
await client.document.updateDocument.mutate(params as any);
|
||||
console.log(`${pc.green('✓')} Updated document ${pc.bold(id)}`);
|
||||
@@ -198,4 +274,98 @@ export function registerDocCommand(program: Command) {
|
||||
|
||||
console.log(`${pc.green('✓')} Deleted ${ids.length} document(s)`);
|
||||
});
|
||||
|
||||
// ── parse ─────────────────────────────────────────────
|
||||
|
||||
doc
|
||||
.command('parse <fileId>')
|
||||
.description('Parse an uploaded file into a document')
|
||||
.option('--with-pages', 'Preserve page structure')
|
||||
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
|
||||
.action(async (fileId: string, options: { json?: string | boolean; withPages?: boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
const result = options.withPages
|
||||
? await client.document.parseFileContent.mutate({ id: fileId })
|
||||
: await client.document.parseDocument.mutate({ id: fileId });
|
||||
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(result, fields);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`${pc.green('✓')} Parsed file ${pc.bold(fileId)}`);
|
||||
if ((result as any).title) console.log(` Title: ${(result as any).title}`);
|
||||
if ((result as any).content) {
|
||||
const preview = truncate((result as any).content, 200);
|
||||
console.log(` Content: ${pc.dim(preview)}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── link-topic ────────────────────────────────────────
|
||||
|
||||
doc
|
||||
.command('link-topic <docId> <topicId>')
|
||||
.description('Associate a document with a topic')
|
||||
.action(async (docId: string, topicId: string) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
// Create the document via notebook router which handles topic association
|
||||
// First verify the document exists
|
||||
const document = await client.document.getDocumentById.query({ id: docId });
|
||||
if (!document) {
|
||||
log.error(`Document not found: ${docId}`);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Use notebook.createDocument to create a linked copy, associating with the topic
|
||||
const result = await client.notebook.createDocument.mutate({
|
||||
content: document.content || '',
|
||||
description: document.description || '',
|
||||
title: document.title || 'Untitled',
|
||||
topicId,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`${pc.green('✓')} Linked document ${pc.bold(result.id)} to topic ${pc.bold(topicId)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// ── topic-docs ────────────────────────────────────────
|
||||
|
||||
doc
|
||||
.command('topic-docs <topicId>')
|
||||
.description('List documents associated with a topic')
|
||||
.option('--type <type>', 'Filter by document type (article, markdown, note, report)')
|
||||
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
|
||||
.action(async (topicId: string, options: { json?: string | boolean; type?: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
const query: { topicId: string; type?: any } = { topicId };
|
||||
if (options.type) query.type = options.type;
|
||||
const result = await client.notebook.listDocuments.query(query);
|
||||
const docs = Array.isArray(result) ? result : ((result as any).data ?? []);
|
||||
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(docs, fields);
|
||||
return;
|
||||
}
|
||||
|
||||
if (docs.length === 0) {
|
||||
console.log('No documents found for this topic.');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = docs.map((d: any) => [
|
||||
d.id,
|
||||
truncate(d.title || 'Untitled', 120),
|
||||
d.fileType || '',
|
||||
d.updatedAt ? timeAgo(d.updatedAt) : '',
|
||||
]);
|
||||
|
||||
printTable(rows, ['ID', 'TITLE', 'TYPE', 'UPDATED']);
|
||||
});
|
||||
}
|
||||
|
||||
+501
-186
@@ -3,6 +3,32 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { mockTrpcClient } = vi.hoisted(() => ({
|
||||
mockTrpcClient: {
|
||||
agentEval: {
|
||||
abortRun: { mutate: vi.fn() },
|
||||
createBenchmark: { mutate: vi.fn() },
|
||||
createDataset: { mutate: vi.fn() },
|
||||
createRun: { mutate: vi.fn() },
|
||||
createTestCase: { mutate: vi.fn() },
|
||||
deleteBenchmark: { mutate: vi.fn() },
|
||||
deleteDataset: { mutate: vi.fn() },
|
||||
deleteRun: { mutate: vi.fn() },
|
||||
deleteTestCase: { mutate: vi.fn() },
|
||||
getBenchmark: { query: vi.fn() },
|
||||
getDataset: { query: vi.fn() },
|
||||
getRunDetails: { query: vi.fn() },
|
||||
getRunProgress: { query: vi.fn() },
|
||||
getRunResults: { query: vi.fn() },
|
||||
getTestCase: { query: vi.fn() },
|
||||
listBenchmarks: { query: vi.fn() },
|
||||
listDatasets: { query: vi.fn() },
|
||||
listRuns: { query: vi.fn() },
|
||||
listTestCases: { query: vi.fn() },
|
||||
retryRunErrors: { mutate: vi.fn() },
|
||||
startRun: { mutate: vi.fn() },
|
||||
updateBenchmark: { mutate: vi.fn() },
|
||||
updateDataset: { mutate: vi.fn() },
|
||||
updateTestCase: { mutate: vi.fn() },
|
||||
},
|
||||
agentEvalExternal: {
|
||||
datasetGet: { query: vi.fn() },
|
||||
messagesList: { query: vi.fn() },
|
||||
@@ -48,9 +74,11 @@ describe('eval command', () => {
|
||||
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
|
||||
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
for (const method of Object.values(mockTrpcClient.agentEvalExternal)) {
|
||||
for (const fn of Object.values(method)) {
|
||||
(fn as ReturnType<typeof vi.fn>).mockReset();
|
||||
for (const ns of Object.values(mockTrpcClient)) {
|
||||
for (const method of Object.values(ns as Record<string, any>)) {
|
||||
for (const fn of Object.values(method as Record<string, any>)) {
|
||||
(fn as ReturnType<typeof vi.fn>).mockReset();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -68,218 +96,505 @@ describe('eval command', () => {
|
||||
return program;
|
||||
};
|
||||
|
||||
it('should call runGet and output json envelope', async () => {
|
||||
mockTrpcClient.agentEvalExternal.runGet.query.mockResolvedValue({
|
||||
config: { k: 1 },
|
||||
datasetId: 'dataset-1',
|
||||
id: 'run-1',
|
||||
// ============================================
|
||||
// Benchmark tests
|
||||
// ============================================
|
||||
describe('benchmark', () => {
|
||||
it('should list benchmarks', async () => {
|
||||
mockTrpcClient.agentEval.listBenchmarks.query.mockResolvedValue([
|
||||
{ id: 'b1', name: 'Bench 1' },
|
||||
]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'eval', 'benchmark', 'list', '--json']);
|
||||
|
||||
expect(mockTrpcClient.agentEval.listBenchmarks.query).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'eval', 'run', 'get', '--run-id', 'run-1', '--json']);
|
||||
it('should create a benchmark', async () => {
|
||||
mockTrpcClient.agentEval.createBenchmark.mutate.mockResolvedValue({ id: 'b1' });
|
||||
|
||||
expect(mockTrpcClient.agentEvalExternal.runGet.query).toHaveBeenCalledWith({ runId: 'run-1' });
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'eval',
|
||||
'benchmark',
|
||||
'create',
|
||||
'--identifier',
|
||||
'test-bench',
|
||||
'-n',
|
||||
'Test Bench',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
const payload = JSON.parse(logSpy.mock.calls[0][0]);
|
||||
expect(payload).toEqual({
|
||||
data: {
|
||||
expect(mockTrpcClient.agentEval.createBenchmark.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ identifier: 'test-bench', name: 'Test Bench' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should delete a benchmark', async () => {
|
||||
mockTrpcClient.agentEval.deleteBenchmark.mutate.mockResolvedValue({ success: true });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'eval', 'benchmark', 'delete', '--id', 'b1']);
|
||||
|
||||
expect(mockTrpcClient.agentEval.deleteBenchmark.mutate).toHaveBeenCalledWith({ id: 'b1' });
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Dataset tests
|
||||
// ============================================
|
||||
describe('dataset', () => {
|
||||
it('should list datasets', async () => {
|
||||
mockTrpcClient.agentEval.listDatasets.query.mockResolvedValue([{ id: 'd1', name: 'DS 1' }]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'eval', 'dataset', 'list', '--json']);
|
||||
|
||||
expect(mockTrpcClient.agentEval.listDatasets.query).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should get dataset via internal API', async () => {
|
||||
mockTrpcClient.agentEval.getDataset.query.mockResolvedValue({ id: 'd1' });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'eval', 'dataset', 'get', '--id', 'd1', '--json']);
|
||||
|
||||
expect(mockTrpcClient.agentEval.getDataset.query).toHaveBeenCalledWith({ id: 'd1' });
|
||||
});
|
||||
|
||||
it('should get dataset via external API with --external', async () => {
|
||||
mockTrpcClient.agentEvalExternal.datasetGet.query.mockResolvedValue({
|
||||
id: 'dataset-1',
|
||||
metadata: { preset: 'deepsearchqa' },
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'eval',
|
||||
'dataset',
|
||||
'get',
|
||||
'--id',
|
||||
'dataset-1',
|
||||
'--external',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentEvalExternal.datasetGet.query).toHaveBeenCalledWith({
|
||||
datasetId: 'dataset-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should create a dataset', async () => {
|
||||
mockTrpcClient.agentEval.createDataset.mutate.mockResolvedValue({ id: 'd1' });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'eval',
|
||||
'dataset',
|
||||
'create',
|
||||
'--benchmark-id',
|
||||
'b1',
|
||||
'--identifier',
|
||||
'ds1',
|
||||
'-n',
|
||||
'Dataset 1',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentEval.createDataset.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ benchmarkId: 'b1', identifier: 'ds1', name: 'Dataset 1' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// TestCase tests
|
||||
// ============================================
|
||||
describe('testcase', () => {
|
||||
it('should list test cases', async () => {
|
||||
mockTrpcClient.agentEval.listTestCases.query.mockResolvedValue({ data: [], total: 0 });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'eval',
|
||||
'testcase',
|
||||
'list',
|
||||
'--dataset-id',
|
||||
'd1',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentEval.listTestCases.query).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ datasetId: 'd1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should create a test case', async () => {
|
||||
mockTrpcClient.agentEval.createTestCase.mutate.mockResolvedValue({ id: 'tc1' });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'eval',
|
||||
'testcase',
|
||||
'create',
|
||||
'--dataset-id',
|
||||
'd1',
|
||||
'--input',
|
||||
'What is 2+2?',
|
||||
'--expected',
|
||||
'4',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentEval.createTestCase.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
content: expect.objectContaining({ expected: '4', input: 'What is 2+2?' }),
|
||||
datasetId: 'd1',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should delete a test case', async () => {
|
||||
mockTrpcClient.agentEval.deleteTestCase.mutate.mockResolvedValue({ success: true });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'eval', 'testcase', 'delete', '--id', 'tc1']);
|
||||
|
||||
expect(mockTrpcClient.agentEval.deleteTestCase.mutate).toHaveBeenCalledWith({ id: 'tc1' });
|
||||
});
|
||||
|
||||
it('should count test cases via external API', async () => {
|
||||
mockTrpcClient.agentEvalExternal.testCasesCount.query.mockResolvedValue({ count: 12 });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'eval',
|
||||
'testcase',
|
||||
'count',
|
||||
'--dataset-id',
|
||||
'dataset-1',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentEvalExternal.testCasesCount.query).toHaveBeenCalledWith({
|
||||
datasetId: 'dataset-1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Run tests
|
||||
// ============================================
|
||||
describe('run', () => {
|
||||
it('should list runs', async () => {
|
||||
mockTrpcClient.agentEval.listRuns.query.mockResolvedValue({ data: [], total: 0 });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'eval', 'run', 'list', '--json']);
|
||||
|
||||
expect(mockTrpcClient.agentEval.listRuns.query).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should get run via internal API', async () => {
|
||||
mockTrpcClient.agentEval.getRunDetails.query.mockResolvedValue({ id: 'r1' });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'eval', 'run', 'get', '--id', 'r1', '--json']);
|
||||
|
||||
expect(mockTrpcClient.agentEval.getRunDetails.query).toHaveBeenCalledWith({ id: 'r1' });
|
||||
});
|
||||
|
||||
it('should get run via external API with --external', async () => {
|
||||
mockTrpcClient.agentEvalExternal.runGet.query.mockResolvedValue({
|
||||
config: { k: 1 },
|
||||
datasetId: 'dataset-1',
|
||||
id: 'run-1',
|
||||
},
|
||||
error: null,
|
||||
ok: true,
|
||||
version: 'v1',
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'eval',
|
||||
'run',
|
||||
'get',
|
||||
'--id',
|
||||
'run-1',
|
||||
'--external',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentEvalExternal.runGet.query).toHaveBeenCalledWith({
|
||||
runId: 'run-1',
|
||||
});
|
||||
|
||||
const payload = JSON.parse(logSpy.mock.calls[0][0]);
|
||||
expect(payload).toEqual({
|
||||
data: { config: { k: 1 }, datasetId: 'dataset-1', id: 'run-1' },
|
||||
error: null,
|
||||
ok: true,
|
||||
version: 'v1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should create a run', async () => {
|
||||
mockTrpcClient.agentEval.createRun.mutate.mockResolvedValue({ id: 'r1' });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'eval',
|
||||
'run',
|
||||
'create',
|
||||
'--dataset-id',
|
||||
'd1',
|
||||
'-n',
|
||||
'Run 1',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentEval.createRun.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ datasetId: 'd1', name: 'Run 1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should start a run', async () => {
|
||||
mockTrpcClient.agentEval.startRun.mutate.mockResolvedValue({ success: true, runId: 'r1' });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'eval', 'run', 'start', '--id', 'r1']);
|
||||
|
||||
expect(mockTrpcClient.agentEval.startRun.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'r1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should abort a run', async () => {
|
||||
mockTrpcClient.agentEval.abortRun.mutate.mockResolvedValue({ success: true });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'eval', 'run', 'abort', '--id', 'r1']);
|
||||
|
||||
expect(mockTrpcClient.agentEval.abortRun.mutate).toHaveBeenCalledWith({ id: 'r1' });
|
||||
});
|
||||
|
||||
it('should get run progress', async () => {
|
||||
mockTrpcClient.agentEval.getRunProgress.query.mockResolvedValue({ status: 'running' });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'eval', 'run', 'progress', '--id', 'r1', '--json']);
|
||||
|
||||
expect(mockTrpcClient.agentEval.getRunProgress.query).toHaveBeenCalledWith({ id: 'r1' });
|
||||
});
|
||||
|
||||
it('should get run results', async () => {
|
||||
mockTrpcClient.agentEval.getRunResults.query.mockResolvedValue({
|
||||
results: [],
|
||||
runId: 'r1',
|
||||
total: 0,
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'eval', 'run', 'results', '--id', 'r1', '--json']);
|
||||
|
||||
expect(mockTrpcClient.agentEval.getRunResults.query).toHaveBeenCalledWith({ id: 'r1' });
|
||||
});
|
||||
|
||||
it('should delete a run', async () => {
|
||||
mockTrpcClient.agentEval.deleteRun.mutate.mockResolvedValue({ success: true });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'eval', 'run', 'delete', '--id', 'r1']);
|
||||
|
||||
expect(mockTrpcClient.agentEval.deleteRun.mutate).toHaveBeenCalledWith({ id: 'r1' });
|
||||
});
|
||||
|
||||
it('should set run status via external API', async () => {
|
||||
mockTrpcClient.agentEvalExternal.runSetStatus.mutate.mockResolvedValue({
|
||||
runId: 'run-1',
|
||||
status: 'completed',
|
||||
success: true,
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'eval',
|
||||
'run',
|
||||
'set-status',
|
||||
'--id',
|
||||
'run-1',
|
||||
'--status',
|
||||
'completed',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentEvalExternal.runSetStatus.mutate).toHaveBeenCalledWith({
|
||||
runId: 'run-1',
|
||||
status: 'completed',
|
||||
});
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('status updated to'));
|
||||
});
|
||||
});
|
||||
|
||||
it('should call datasetGet and output json envelope', async () => {
|
||||
mockTrpcClient.agentEvalExternal.datasetGet.query.mockResolvedValue({
|
||||
id: 'dataset-1',
|
||||
metadata: { preset: 'deepsearchqa' },
|
||||
// ============================================
|
||||
// Run-Topic tests (external eval API)
|
||||
// ============================================
|
||||
describe('run-topic', () => {
|
||||
it('should list run topics', async () => {
|
||||
mockTrpcClient.agentEvalExternal.runTopicsList.query.mockResolvedValue([]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'eval',
|
||||
'run-topic',
|
||||
'list',
|
||||
'--run-id',
|
||||
'run-1',
|
||||
'--only-external',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentEvalExternal.runTopicsList.query).toHaveBeenCalledWith({
|
||||
onlyExternal: true,
|
||||
runId: 'run-1',
|
||||
});
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'eval',
|
||||
'dataset',
|
||||
'get',
|
||||
'--dataset-id',
|
||||
'dataset-1',
|
||||
'--json',
|
||||
]);
|
||||
it('should report run-topic result', async () => {
|
||||
mockTrpcClient.agentEvalExternal.runTopicReportResult.mutate.mockResolvedValue({
|
||||
success: true,
|
||||
});
|
||||
|
||||
expect(mockTrpcClient.agentEvalExternal.datasetGet.query).toHaveBeenCalledWith({
|
||||
datasetId: 'dataset-1',
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'eval',
|
||||
'run-topic',
|
||||
'report-result',
|
||||
'--run-id',
|
||||
'run-1',
|
||||
'--topic-id',
|
||||
'topic-1',
|
||||
'--thread-id',
|
||||
'thread-1',
|
||||
'--score',
|
||||
'0.91',
|
||||
'--correct',
|
||||
'true',
|
||||
'--result-json',
|
||||
'{"grade":"A"}',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentEvalExternal.runTopicReportResult.mutate).toHaveBeenCalledWith({
|
||||
correct: true,
|
||||
result: { grade: 'A' },
|
||||
runId: 'run-1',
|
||||
score: 0.91,
|
||||
threadId: 'thread-1',
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass onlyExternal to runTopicsList', async () => {
|
||||
mockTrpcClient.agentEvalExternal.runTopicsList.query.mockResolvedValue([]);
|
||||
// ============================================
|
||||
// Eval thread/message tests (external eval API)
|
||||
// ============================================
|
||||
describe('eval thread', () => {
|
||||
it('should list threads by topic', async () => {
|
||||
mockTrpcClient.agentEvalExternal.threadsList.query.mockResolvedValue([]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'eval',
|
||||
'run-topics',
|
||||
'list',
|
||||
'--run-id',
|
||||
'run-1',
|
||||
'--only-external',
|
||||
'--json',
|
||||
]);
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'eval',
|
||||
'thread',
|
||||
'list',
|
||||
'--topic-id',
|
||||
'topic-1',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentEvalExternal.runTopicsList.query).toHaveBeenCalledWith({
|
||||
onlyExternal: true,
|
||||
runId: 'run-1',
|
||||
expect(mockTrpcClient.agentEvalExternal.threadsList.query).toHaveBeenCalledWith({
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass topicId and threadId to messagesList', async () => {
|
||||
mockTrpcClient.agentEvalExternal.messagesList.query.mockResolvedValue([]);
|
||||
describe('eval message', () => {
|
||||
it('should list messages by topic and thread', async () => {
|
||||
mockTrpcClient.agentEvalExternal.messagesList.query.mockResolvedValue([]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'eval',
|
||||
'messages',
|
||||
'list',
|
||||
'--topic-id',
|
||||
'topic-1',
|
||||
'--thread-id',
|
||||
'thread-1',
|
||||
'--json',
|
||||
]);
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'eval',
|
||||
'message',
|
||||
'list',
|
||||
'--topic-id',
|
||||
'topic-1',
|
||||
'--thread-id',
|
||||
'thread-1',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentEvalExternal.messagesList.query).toHaveBeenCalledWith({
|
||||
threadId: 'thread-1',
|
||||
topicId: 'topic-1',
|
||||
expect(mockTrpcClient.agentEvalExternal.messagesList.query).toHaveBeenCalledWith({
|
||||
threadId: 'thread-1',
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse and report run-topic result', async () => {
|
||||
mockTrpcClient.agentEvalExternal.runTopicReportResult.mutate.mockResolvedValue({
|
||||
success: true,
|
||||
// ============================================
|
||||
// Error handling
|
||||
// ============================================
|
||||
describe('error handling', () => {
|
||||
it('should output json error envelope when command fails', async () => {
|
||||
const error = Object.assign(new Error('Run not found'), {
|
||||
data: { code: 'NOT_FOUND' },
|
||||
});
|
||||
mockTrpcClient.agentEval.getRunDetails.query.mockRejectedValue(error);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'eval', 'run', 'get', '--id', 'run-404', '--json']);
|
||||
|
||||
const payload = JSON.parse(logSpy.mock.calls[0][0]);
|
||||
expect(payload).toEqual({
|
||||
data: null,
|
||||
error: { code: 'NOT_FOUND', message: 'Run not found' },
|
||||
ok: false,
|
||||
version: 'v1',
|
||||
});
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'eval',
|
||||
'run-topic',
|
||||
'report-result',
|
||||
'--run-id',
|
||||
'run-1',
|
||||
'--topic-id',
|
||||
'topic-1',
|
||||
'--thread-id',
|
||||
'thread-1',
|
||||
'--score',
|
||||
'0.91',
|
||||
'--correct',
|
||||
'true',
|
||||
'--result-json',
|
||||
'{"grade":"A"}',
|
||||
'--json',
|
||||
]);
|
||||
it('should log plain error without --json', async () => {
|
||||
mockTrpcClient.agentEvalExternal.threadsList.query.mockRejectedValue(new Error('boom'));
|
||||
|
||||
expect(mockTrpcClient.agentEvalExternal.runTopicReportResult.mutate).toHaveBeenCalledWith({
|
||||
correct: true,
|
||||
result: { grade: 'A' },
|
||||
runId: 'run-1',
|
||||
score: 0.91,
|
||||
threadId: 'thread-1',
|
||||
topicId: 'topic-1',
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'eval', 'thread', 'list', '--topic-id', 'topic-1']);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith('boom');
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('should update run status', async () => {
|
||||
mockTrpcClient.agentEvalExternal.runSetStatus.mutate.mockResolvedValue({
|
||||
runId: 'run-1',
|
||||
status: 'completed',
|
||||
success: true,
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'eval',
|
||||
'run',
|
||||
'set-status',
|
||||
'--run-id',
|
||||
'run-1',
|
||||
'--status',
|
||||
'completed',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentEvalExternal.runSetStatus.mutate).toHaveBeenCalledWith({
|
||||
runId: 'run-1',
|
||||
status: 'completed',
|
||||
});
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('status updated to'));
|
||||
});
|
||||
|
||||
it('should output json error envelope when command fails', async () => {
|
||||
const error = Object.assign(new Error('Run not found'), {
|
||||
data: { code: 'NOT_FOUND' },
|
||||
});
|
||||
mockTrpcClient.agentEvalExternal.runGet.query.mockRejectedValue(error);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'eval',
|
||||
'run',
|
||||
'get',
|
||||
'--run-id',
|
||||
'run-404',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
const payload = JSON.parse(logSpy.mock.calls[0][0]);
|
||||
expect(payload).toEqual({
|
||||
data: null,
|
||||
error: { code: 'NOT_FOUND', message: 'Run not found' },
|
||||
ok: false,
|
||||
version: 'v1',
|
||||
});
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should query test case count', async () => {
|
||||
mockTrpcClient.agentEvalExternal.testCasesCount.query.mockResolvedValue({ count: 12 });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'eval',
|
||||
'test-cases',
|
||||
'count',
|
||||
'--dataset-id',
|
||||
'dataset-1',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentEvalExternal.testCasesCount.query).toHaveBeenCalledWith({
|
||||
datasetId: 'dataset-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should log plain error without --json', async () => {
|
||||
mockTrpcClient.agentEvalExternal.threadsList.query.mockRejectedValue(new Error('boom'));
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'eval', 'threads', 'list', '--topic-id', 'topic-1']);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith('boom');
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
+614
-132
@@ -23,46 +23,6 @@ interface JsonOption {
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
interface RunGetOptions extends JsonOption {
|
||||
runId: string;
|
||||
}
|
||||
|
||||
interface RunSetStatusOptions extends JsonOption {
|
||||
runId: string;
|
||||
status: 'completed' | 'external';
|
||||
}
|
||||
|
||||
interface DatasetGetOptions extends JsonOption {
|
||||
datasetId: string;
|
||||
}
|
||||
|
||||
interface RunTopicsListOptions extends JsonOption {
|
||||
onlyExternal?: boolean;
|
||||
runId: string;
|
||||
}
|
||||
|
||||
interface ThreadsListOptions extends JsonOption {
|
||||
topicId: string;
|
||||
}
|
||||
|
||||
interface MessagesListOptions extends JsonOption {
|
||||
threadId?: string;
|
||||
topicId: string;
|
||||
}
|
||||
|
||||
interface TestCasesCountOptions extends JsonOption {
|
||||
datasetId: string;
|
||||
}
|
||||
|
||||
interface RunTopicReportResultOptions extends JsonOption {
|
||||
correct: boolean;
|
||||
resultJson: Record<string, unknown>;
|
||||
runId: string;
|
||||
score: number;
|
||||
threadId?: string;
|
||||
topicId: string;
|
||||
}
|
||||
|
||||
const printJson = (data: unknown) => {
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
};
|
||||
@@ -180,65 +140,587 @@ const executeCommand = async (
|
||||
};
|
||||
|
||||
export function registerEvalCommand(program: Command) {
|
||||
const evalCmd = program.command('eval').description('Manage external evaluation workflows');
|
||||
const evalCmd = program.command('eval').description('Manage evaluation workflows');
|
||||
|
||||
// ============================================
|
||||
// Benchmark Operations
|
||||
// ============================================
|
||||
const benchmarkCmd = evalCmd.command('benchmark').description('Manage evaluation benchmarks');
|
||||
|
||||
benchmarkCmd
|
||||
.command('list')
|
||||
.description('List benchmarks')
|
||||
.option('--include-system', 'Include system benchmarks')
|
||||
.option('--json', 'Output JSON envelope')
|
||||
.action(async (options: JsonOption & { includeSystem?: boolean }) =>
|
||||
executeCommand(options, async () => {
|
||||
const client = await getTrpcClient();
|
||||
return client.agentEval.listBenchmarks.query({
|
||||
includeSystem: options.includeSystem ?? true,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
benchmarkCmd
|
||||
.command('get')
|
||||
.description('Get benchmark details')
|
||||
.requiredOption('--id <id>', 'Benchmark ID')
|
||||
.option('--json', 'Output JSON envelope')
|
||||
.action(async (options: JsonOption & { id: string }) =>
|
||||
executeCommand(options, async () => {
|
||||
const client = await getTrpcClient();
|
||||
return client.agentEval.getBenchmark.query({ id: options.id });
|
||||
}),
|
||||
);
|
||||
|
||||
benchmarkCmd
|
||||
.command('create')
|
||||
.description('Create a benchmark')
|
||||
.requiredOption('--identifier <identifier>', 'Unique identifier')
|
||||
.requiredOption('-n, --name <name>', 'Benchmark name')
|
||||
.option('-d, --description <desc>', 'Description')
|
||||
.option('--reference-url <url>', 'Reference URL')
|
||||
.option('--json', 'Output JSON envelope')
|
||||
.action(
|
||||
async (
|
||||
options: JsonOption & {
|
||||
description?: string;
|
||||
identifier: string;
|
||||
name: string;
|
||||
referenceUrl?: string;
|
||||
},
|
||||
) =>
|
||||
executeCommand(
|
||||
options,
|
||||
async () => {
|
||||
const client = await getTrpcClient();
|
||||
const input: Record<string, any> = {
|
||||
identifier: options.identifier,
|
||||
name: options.name,
|
||||
};
|
||||
if (options.description) input.description = options.description;
|
||||
if (options.referenceUrl) input.referenceUrl = options.referenceUrl;
|
||||
return client.agentEval.createBenchmark.mutate(input as any);
|
||||
},
|
||||
`Created benchmark ${pc.bold(options.name)}`,
|
||||
),
|
||||
);
|
||||
|
||||
benchmarkCmd
|
||||
.command('update')
|
||||
.description('Update a benchmark')
|
||||
.requiredOption('--id <id>', 'Benchmark ID')
|
||||
.option('-n, --name <name>', 'New name')
|
||||
.option('-d, --description <desc>', 'New description')
|
||||
.option('--reference-url <url>', 'New reference URL')
|
||||
.option('--json', 'Output JSON envelope')
|
||||
.action(
|
||||
async (
|
||||
options: JsonOption & {
|
||||
description?: string;
|
||||
id: string;
|
||||
name?: string;
|
||||
referenceUrl?: string;
|
||||
},
|
||||
) =>
|
||||
executeCommand(
|
||||
options,
|
||||
async () => {
|
||||
const client = await getTrpcClient();
|
||||
const input: Record<string, any> = { id: options.id };
|
||||
if (options.name) input.name = options.name;
|
||||
if (options.description) input.description = options.description;
|
||||
if (options.referenceUrl) input.referenceUrl = options.referenceUrl;
|
||||
return client.agentEval.updateBenchmark.mutate(input as any);
|
||||
},
|
||||
`Updated benchmark ${pc.bold(options.id)}`,
|
||||
),
|
||||
);
|
||||
|
||||
benchmarkCmd
|
||||
.command('delete')
|
||||
.description('Delete a benchmark')
|
||||
.requiredOption('--id <id>', 'Benchmark ID')
|
||||
.option('--json', 'Output JSON envelope')
|
||||
.action(async (options: JsonOption & { id: string }) =>
|
||||
executeCommand(
|
||||
options,
|
||||
async () => {
|
||||
const client = await getTrpcClient();
|
||||
return client.agentEval.deleteBenchmark.mutate({ id: options.id });
|
||||
},
|
||||
`Deleted benchmark ${pc.bold(options.id)}`,
|
||||
),
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// Dataset Operations
|
||||
// ============================================
|
||||
const datasetCmd = evalCmd.command('dataset').description('Manage evaluation datasets');
|
||||
|
||||
datasetCmd
|
||||
.command('list')
|
||||
.description('List datasets')
|
||||
.option('--benchmark-id <id>', 'Filter by benchmark ID')
|
||||
.option('--json', 'Output JSON envelope')
|
||||
.action(async (options: JsonOption & { benchmarkId?: string }) =>
|
||||
executeCommand(options, async () => {
|
||||
const client = await getTrpcClient();
|
||||
return client.agentEval.listDatasets.query(
|
||||
options.benchmarkId ? { benchmarkId: options.benchmarkId } : undefined,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
datasetCmd
|
||||
.command('get')
|
||||
.description('Get dataset details (use --external for external eval API)')
|
||||
.requiredOption('--id <id>', 'Dataset ID')
|
||||
.option('--external', 'Use external evaluation API')
|
||||
.option('--json', 'Output JSON envelope')
|
||||
.action(async (options: JsonOption & { external?: boolean; id: string }) =>
|
||||
executeCommand(options, async () => {
|
||||
const client = await getTrpcClient();
|
||||
if (options.external) {
|
||||
return client.agentEvalExternal.datasetGet.query({ datasetId: options.id });
|
||||
}
|
||||
return client.agentEval.getDataset.query({ id: options.id });
|
||||
}),
|
||||
);
|
||||
|
||||
datasetCmd
|
||||
.command('create')
|
||||
.description('Create a dataset')
|
||||
.requiredOption('--benchmark-id <id>', 'Benchmark ID')
|
||||
.requiredOption('--identifier <identifier>', 'Unique identifier')
|
||||
.requiredOption('-n, --name <name>', 'Dataset name')
|
||||
.option('-d, --description <desc>', 'Description')
|
||||
.option('--eval-mode <mode>', 'Evaluation mode')
|
||||
.option('--json', 'Output JSON envelope')
|
||||
.action(
|
||||
async (
|
||||
options: JsonOption & {
|
||||
benchmarkId: string;
|
||||
description?: string;
|
||||
evalMode?: string;
|
||||
identifier: string;
|
||||
name: string;
|
||||
},
|
||||
) =>
|
||||
executeCommand(
|
||||
options,
|
||||
async () => {
|
||||
const client = await getTrpcClient();
|
||||
const input: Record<string, any> = {
|
||||
benchmarkId: options.benchmarkId,
|
||||
identifier: options.identifier,
|
||||
name: options.name,
|
||||
};
|
||||
if (options.description) input.description = options.description;
|
||||
if (options.evalMode) input.evalMode = options.evalMode;
|
||||
return client.agentEval.createDataset.mutate(input as any);
|
||||
},
|
||||
`Created dataset ${pc.bold(options.name)}`,
|
||||
),
|
||||
);
|
||||
|
||||
datasetCmd
|
||||
.command('update')
|
||||
.description('Update a dataset')
|
||||
.requiredOption('--id <id>', 'Dataset ID')
|
||||
.option('-n, --name <name>', 'New name')
|
||||
.option('-d, --description <desc>', 'New description')
|
||||
.option('--eval-mode <mode>', 'New evaluation mode')
|
||||
.option('--json', 'Output JSON envelope')
|
||||
.action(
|
||||
async (
|
||||
options: JsonOption & {
|
||||
description?: string;
|
||||
evalMode?: string;
|
||||
id: string;
|
||||
name?: string;
|
||||
},
|
||||
) =>
|
||||
executeCommand(
|
||||
options,
|
||||
async () => {
|
||||
const client = await getTrpcClient();
|
||||
const input: Record<string, any> = { id: options.id };
|
||||
if (options.name) input.name = options.name;
|
||||
if (options.description) input.description = options.description;
|
||||
if (options.evalMode) input.evalMode = options.evalMode;
|
||||
return client.agentEval.updateDataset.mutate(input as any);
|
||||
},
|
||||
`Updated dataset ${pc.bold(options.id)}`,
|
||||
),
|
||||
);
|
||||
|
||||
datasetCmd
|
||||
.command('delete')
|
||||
.description('Delete a dataset')
|
||||
.requiredOption('--id <id>', 'Dataset ID')
|
||||
.option('--json', 'Output JSON envelope')
|
||||
.action(async (options: JsonOption & { id: string }) =>
|
||||
executeCommand(
|
||||
options,
|
||||
async () => {
|
||||
const client = await getTrpcClient();
|
||||
return client.agentEval.deleteDataset.mutate({ id: options.id });
|
||||
},
|
||||
`Deleted dataset ${pc.bold(options.id)}`,
|
||||
),
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// TestCase Operations
|
||||
// ============================================
|
||||
const testcaseCmd = evalCmd.command('testcase').description('Manage evaluation test cases');
|
||||
|
||||
testcaseCmd
|
||||
.command('list')
|
||||
.description('List test cases')
|
||||
.requiredOption('--dataset-id <id>', 'Dataset ID')
|
||||
.option('-L, --limit <n>', 'Page size', '50')
|
||||
.option('--offset <n>', 'Offset', '0')
|
||||
.option('--json', 'Output JSON envelope')
|
||||
.action(async (options: JsonOption & { datasetId: string; limit?: string; offset?: string }) =>
|
||||
executeCommand(options, async () => {
|
||||
const client = await getTrpcClient();
|
||||
return client.agentEval.listTestCases.query({
|
||||
datasetId: options.datasetId,
|
||||
limit: Number.parseInt(options.limit || '50', 10),
|
||||
offset: Number.parseInt(options.offset || '0', 10),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
testcaseCmd
|
||||
.command('get')
|
||||
.description('Get test case details')
|
||||
.requiredOption('--id <id>', 'Test case ID')
|
||||
.option('--json', 'Output JSON envelope')
|
||||
.action(async (options: JsonOption & { id: string }) =>
|
||||
executeCommand(options, async () => {
|
||||
const client = await getTrpcClient();
|
||||
return client.agentEval.getTestCase.query({ id: options.id });
|
||||
}),
|
||||
);
|
||||
|
||||
testcaseCmd
|
||||
.command('create')
|
||||
.description('Create a test case')
|
||||
.requiredOption('--dataset-id <id>', 'Dataset ID')
|
||||
.requiredOption('--input <text>', 'Input text')
|
||||
.option('--expected <text>', 'Expected output')
|
||||
.option('--category <cat>', 'Category')
|
||||
.option('--sort-order <n>', 'Sort order')
|
||||
.option('--json', 'Output JSON envelope')
|
||||
.action(
|
||||
async (
|
||||
options: JsonOption & {
|
||||
category?: string;
|
||||
datasetId: string;
|
||||
expected?: string;
|
||||
input: string;
|
||||
sortOrder?: string;
|
||||
},
|
||||
) =>
|
||||
executeCommand(
|
||||
options,
|
||||
async () => {
|
||||
const client = await getTrpcClient();
|
||||
const content: Record<string, any> = { input: options.input };
|
||||
if (options.expected) content.expected = options.expected;
|
||||
if (options.category) content.category = options.category;
|
||||
|
||||
const input: Record<string, any> = { content, datasetId: options.datasetId };
|
||||
if (options.sortOrder) input.sortOrder = Number.parseInt(options.sortOrder, 10);
|
||||
return client.agentEval.createTestCase.mutate(input as any);
|
||||
},
|
||||
'Created test case',
|
||||
),
|
||||
);
|
||||
|
||||
testcaseCmd
|
||||
.command('update')
|
||||
.description('Update a test case')
|
||||
.requiredOption('--id <id>', 'Test case ID')
|
||||
.option('--input <text>', 'New input text')
|
||||
.option('--expected <text>', 'New expected output')
|
||||
.option('--category <cat>', 'New category')
|
||||
.option('--sort-order <n>', 'New sort order')
|
||||
.option('--json', 'Output JSON envelope')
|
||||
.action(
|
||||
async (
|
||||
options: JsonOption & {
|
||||
category?: string;
|
||||
expected?: string;
|
||||
id: string;
|
||||
input?: string;
|
||||
sortOrder?: string;
|
||||
},
|
||||
) =>
|
||||
executeCommand(
|
||||
options,
|
||||
async () => {
|
||||
const client = await getTrpcClient();
|
||||
const input: Record<string, any> = { id: options.id };
|
||||
const content: Record<string, any> = {};
|
||||
if (options.input) content.input = options.input;
|
||||
if (options.expected) content.expected = options.expected;
|
||||
if (options.category) content.category = options.category;
|
||||
if (Object.keys(content).length > 0) input.content = content;
|
||||
if (options.sortOrder) input.sortOrder = Number.parseInt(options.sortOrder, 10);
|
||||
return client.agentEval.updateTestCase.mutate(input as any);
|
||||
},
|
||||
`Updated test case ${pc.bold(options.id)}`,
|
||||
),
|
||||
);
|
||||
|
||||
testcaseCmd
|
||||
.command('delete')
|
||||
.description('Delete a test case')
|
||||
.requiredOption('--id <id>', 'Test case ID')
|
||||
.option('--json', 'Output JSON envelope')
|
||||
.action(async (options: JsonOption & { id: string }) =>
|
||||
executeCommand(
|
||||
options,
|
||||
async () => {
|
||||
const client = await getTrpcClient();
|
||||
return client.agentEval.deleteTestCase.mutate({ id: options.id });
|
||||
},
|
||||
`Deleted test case ${pc.bold(options.id)}`,
|
||||
),
|
||||
);
|
||||
|
||||
testcaseCmd
|
||||
.command('count')
|
||||
.description('Count test cases by dataset (external eval API)')
|
||||
.requiredOption('--dataset-id <id>', 'Dataset ID')
|
||||
.option('--json', 'Output JSON envelope')
|
||||
.action(async (options: JsonOption & { datasetId: string }) =>
|
||||
executeCommand(options, async () => {
|
||||
const client = await getTrpcClient();
|
||||
return client.agentEvalExternal.testCasesCount.query({ datasetId: options.datasetId });
|
||||
}),
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// Run Operations
|
||||
// ============================================
|
||||
const runCmd = evalCmd.command('run').description('Manage evaluation runs');
|
||||
|
||||
runCmd
|
||||
.command('get')
|
||||
.description('Get run information')
|
||||
.requiredOption('--run-id <id>', 'Run ID')
|
||||
.command('list')
|
||||
.description('List evaluation runs')
|
||||
.option('--benchmark-id <id>', 'Filter by benchmark ID')
|
||||
.option('--dataset-id <id>', 'Filter by dataset ID')
|
||||
.option('--status <status>', 'Filter by status')
|
||||
.option('-L, --limit <n>', 'Page size', '50')
|
||||
.option('--offset <n>', 'Offset', '0')
|
||||
.option('--json', 'Output JSON envelope')
|
||||
.action(async (options: RunGetOptions) =>
|
||||
.action(
|
||||
async (
|
||||
options: JsonOption & {
|
||||
benchmarkId?: string;
|
||||
datasetId?: string;
|
||||
limit?: string;
|
||||
offset?: string;
|
||||
status?: string;
|
||||
},
|
||||
) =>
|
||||
executeCommand(options, async () => {
|
||||
const client = await getTrpcClient();
|
||||
const input: Record<string, any> = {};
|
||||
if (options.benchmarkId) input.benchmarkId = options.benchmarkId;
|
||||
if (options.datasetId) input.datasetId = options.datasetId;
|
||||
if (options.status) input.status = options.status;
|
||||
input.limit = Number.parseInt(options.limit || '50', 10);
|
||||
input.offset = Number.parseInt(options.offset || '0', 10);
|
||||
return client.agentEval.listRuns.query(input as any);
|
||||
}),
|
||||
);
|
||||
|
||||
runCmd
|
||||
.command('get')
|
||||
.description('Get run details (use --external for external eval API)')
|
||||
.requiredOption('--id <id>', 'Run ID')
|
||||
.option('--external', 'Use external evaluation API')
|
||||
.option('--json', 'Output JSON envelope')
|
||||
.action(async (options: JsonOption & { external?: boolean; id: string }) =>
|
||||
executeCommand(options, async () => {
|
||||
const client = await getTrpcClient();
|
||||
return client.agentEvalExternal.runGet.query({ runId: options.runId });
|
||||
if (options.external) {
|
||||
return client.agentEvalExternal.runGet.query({ runId: options.id });
|
||||
}
|
||||
return client.agentEval.getRunDetails.query({ id: options.id });
|
||||
}),
|
||||
);
|
||||
|
||||
runCmd
|
||||
.command('create')
|
||||
.description('Create an evaluation run')
|
||||
.requiredOption('--dataset-id <id>', 'Dataset ID')
|
||||
.option('--agent-id <id>', 'Target agent ID')
|
||||
.option('-n, --name <name>', 'Run name')
|
||||
.option('--k <n>', 'Number of runs per test case (1-10)')
|
||||
.option('--max-concurrency <n>', 'Max concurrency (1-10)')
|
||||
.option('--max-steps <n>', 'Max steps (1-1000)')
|
||||
.option('--timeout <ms>', 'Timeout in ms (60000-3600000)')
|
||||
.option('--json', 'Output JSON envelope')
|
||||
.action(
|
||||
async (
|
||||
options: JsonOption & {
|
||||
agentId?: string;
|
||||
datasetId: string;
|
||||
k?: string;
|
||||
maxConcurrency?: string;
|
||||
maxSteps?: string;
|
||||
name?: string;
|
||||
timeout?: string;
|
||||
},
|
||||
) =>
|
||||
executeCommand(
|
||||
options,
|
||||
async () => {
|
||||
const client = await getTrpcClient();
|
||||
const input: Record<string, any> = { datasetId: options.datasetId };
|
||||
if (options.agentId) input.targetAgentId = options.agentId;
|
||||
if (options.name) input.name = options.name;
|
||||
const config: Record<string, any> = {};
|
||||
if (options.k) config.k = Number.parseInt(options.k, 10);
|
||||
if (options.maxConcurrency)
|
||||
config.maxConcurrency = Number.parseInt(options.maxConcurrency, 10);
|
||||
if (options.maxSteps) config.maxSteps = Number.parseInt(options.maxSteps, 10);
|
||||
if (options.timeout) config.timeout = Number.parseInt(options.timeout, 10);
|
||||
if (Object.keys(config).length > 0) input.config = config;
|
||||
return client.agentEval.createRun.mutate(input as any);
|
||||
},
|
||||
'Created evaluation run',
|
||||
),
|
||||
);
|
||||
|
||||
runCmd
|
||||
.command('delete')
|
||||
.description('Delete an evaluation run')
|
||||
.requiredOption('--id <id>', 'Run ID')
|
||||
.option('--json', 'Output JSON envelope')
|
||||
.action(async (options: JsonOption & { id: string }) =>
|
||||
executeCommand(
|
||||
options,
|
||||
async () => {
|
||||
const client = await getTrpcClient();
|
||||
return client.agentEval.deleteRun.mutate({ id: options.id });
|
||||
},
|
||||
`Deleted run ${pc.bold(options.id)}`,
|
||||
),
|
||||
);
|
||||
|
||||
runCmd
|
||||
.command('start')
|
||||
.description('Start an evaluation run')
|
||||
.requiredOption('--id <id>', 'Run ID')
|
||||
.option('--force', 'Force restart even if already running')
|
||||
.option('--json', 'Output JSON envelope')
|
||||
.action(async (options: JsonOption & { force?: boolean; id: string }) =>
|
||||
executeCommand(
|
||||
options,
|
||||
async () => {
|
||||
const client = await getTrpcClient();
|
||||
return client.agentEval.startRun.mutate({ id: options.id, force: options.force });
|
||||
},
|
||||
`Started run ${pc.bold(options.id)}`,
|
||||
),
|
||||
);
|
||||
|
||||
runCmd
|
||||
.command('abort')
|
||||
.description('Abort a running evaluation')
|
||||
.requiredOption('--id <id>', 'Run ID')
|
||||
.option('--json', 'Output JSON envelope')
|
||||
.action(async (options: JsonOption & { id: string }) =>
|
||||
executeCommand(
|
||||
options,
|
||||
async () => {
|
||||
const client = await getTrpcClient();
|
||||
return client.agentEval.abortRun.mutate({ id: options.id });
|
||||
},
|
||||
`Aborted run ${pc.bold(options.id)}`,
|
||||
),
|
||||
);
|
||||
|
||||
runCmd
|
||||
.command('retry-errors')
|
||||
.description('Retry failed test cases in a run')
|
||||
.requiredOption('--id <id>', 'Run ID')
|
||||
.option('--json', 'Output JSON envelope')
|
||||
.action(async (options: JsonOption & { id: string }) =>
|
||||
executeCommand(
|
||||
options,
|
||||
async () => {
|
||||
const client = await getTrpcClient();
|
||||
return client.agentEval.retryRunErrors.mutate({ id: options.id });
|
||||
},
|
||||
`Retrying errors for run ${pc.bold(options.id)}`,
|
||||
),
|
||||
);
|
||||
|
||||
runCmd
|
||||
.command('progress')
|
||||
.description('Get run progress')
|
||||
.requiredOption('--id <id>', 'Run ID')
|
||||
.option('--json', 'Output JSON envelope')
|
||||
.action(async (options: JsonOption & { id: string }) =>
|
||||
executeCommand(options, async () => {
|
||||
const client = await getTrpcClient();
|
||||
return client.agentEval.getRunProgress.query({ id: options.id });
|
||||
}),
|
||||
);
|
||||
|
||||
runCmd
|
||||
.command('results')
|
||||
.description('Get run results')
|
||||
.requiredOption('--id <id>', 'Run ID')
|
||||
.option('--json', 'Output JSON envelope')
|
||||
.action(async (options: JsonOption & { id: string }) =>
|
||||
executeCommand(options, async () => {
|
||||
const client = await getTrpcClient();
|
||||
return client.agentEval.getRunResults.query({ id: options.id });
|
||||
}),
|
||||
);
|
||||
|
||||
runCmd
|
||||
.command('set-status')
|
||||
.description('Set run status (external API supports completed or external)')
|
||||
.requiredOption('--run-id <id>', 'Run ID')
|
||||
.description('Set run status (external eval API, supports completed or external)')
|
||||
.requiredOption('--id <id>', 'Run ID')
|
||||
.requiredOption('--status <status>', 'Status (completed | external)', parseRunStatus)
|
||||
.option('--json', 'Output JSON envelope')
|
||||
.action(async (options: RunSetStatusOptions) =>
|
||||
.action(async (options: JsonOption & { id: string; status: 'completed' | 'external' }) =>
|
||||
executeCommand(
|
||||
options,
|
||||
async () => {
|
||||
const client = await getTrpcClient();
|
||||
return client.agentEvalExternal.runSetStatus.mutate({
|
||||
runId: options.runId,
|
||||
runId: options.id,
|
||||
status: options.status,
|
||||
});
|
||||
},
|
||||
`Run ${pc.bold(options.runId)} status updated to ${pc.bold(options.status)}`,
|
||||
`Run ${pc.bold(options.id)} status updated to ${pc.bold(options.status)}`,
|
||||
),
|
||||
);
|
||||
|
||||
evalCmd
|
||||
.command('dataset')
|
||||
.description('Manage evaluation datasets')
|
||||
.command('get')
|
||||
.description('Get dataset information')
|
||||
.requiredOption('--dataset-id <id>', 'Dataset ID')
|
||||
.option('--json', 'Output JSON envelope')
|
||||
.action(async (options: DatasetGetOptions) =>
|
||||
executeCommand(options, async () => {
|
||||
const client = await getTrpcClient();
|
||||
return client.agentEvalExternal.datasetGet.query({ datasetId: options.datasetId });
|
||||
}),
|
||||
);
|
||||
// ============================================
|
||||
// Run-Topic Operations (external eval API)
|
||||
// ============================================
|
||||
const runTopicCmd = evalCmd.command('run-topic').description('Manage evaluation run topics');
|
||||
|
||||
evalCmd
|
||||
.command('run-topics')
|
||||
.description('Manage run topics')
|
||||
runTopicCmd
|
||||
.command('list')
|
||||
.description('List topics in a run')
|
||||
.requiredOption('--run-id <id>', 'Run ID')
|
||||
.option('--only-external', 'Only return topics pending external evaluation')
|
||||
.option('--json', 'Output JSON envelope')
|
||||
.action(async (options: RunTopicsListOptions) =>
|
||||
.action(async (options: JsonOption & { onlyExternal?: boolean; runId: string }) =>
|
||||
executeCommand(options, async () => {
|
||||
const client = await getTrpcClient();
|
||||
return client.agentEvalExternal.runTopicsList.query({
|
||||
@@ -248,55 +730,7 @@ export function registerEvalCommand(program: Command) {
|
||||
}),
|
||||
);
|
||||
|
||||
evalCmd
|
||||
.command('threads')
|
||||
.description('Manage evaluation threads')
|
||||
.command('list')
|
||||
.description('List threads by topic')
|
||||
.requiredOption('--topic-id <id>', 'Topic ID')
|
||||
.option('--json', 'Output JSON envelope')
|
||||
.action(async (options: ThreadsListOptions) =>
|
||||
executeCommand(options, async () => {
|
||||
const client = await getTrpcClient();
|
||||
return client.agentEvalExternal.threadsList.query({ topicId: options.topicId });
|
||||
}),
|
||||
);
|
||||
|
||||
evalCmd
|
||||
.command('messages')
|
||||
.description('Manage evaluation messages')
|
||||
.command('list')
|
||||
.description('List messages by topic and optional thread')
|
||||
.requiredOption('--topic-id <id>', 'Topic ID')
|
||||
.option('--thread-id <id>', 'Thread ID')
|
||||
.option('--json', 'Output JSON envelope')
|
||||
.action(async (options: MessagesListOptions) =>
|
||||
executeCommand(options, async () => {
|
||||
const client = await getTrpcClient();
|
||||
return client.agentEvalExternal.messagesList.query({
|
||||
threadId: options.threadId,
|
||||
topicId: options.topicId,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
evalCmd
|
||||
.command('test-cases')
|
||||
.description('Manage evaluation test cases')
|
||||
.command('count')
|
||||
.description('Count test cases by dataset')
|
||||
.requiredOption('--dataset-id <id>', 'Dataset ID')
|
||||
.option('--json', 'Output JSON envelope')
|
||||
.action(async (options: TestCasesCountOptions) =>
|
||||
executeCommand(options, async () => {
|
||||
const client = await getTrpcClient();
|
||||
return client.agentEvalExternal.testCasesCount.query({ datasetId: options.datasetId });
|
||||
}),
|
||||
);
|
||||
|
||||
evalCmd
|
||||
.command('run-topic')
|
||||
.description('Manage evaluation run-topic reporting')
|
||||
runTopicCmd
|
||||
.command('report-result')
|
||||
.description('Report one evaluation result for a run topic')
|
||||
.requiredOption('--run-id <id>', 'Run ID')
|
||||
@@ -306,21 +740,69 @@ export function registerEvalCommand(program: Command) {
|
||||
.requiredOption('--correct <boolean>', 'Whether the result is correct', parseBoolean)
|
||||
.requiredOption('--result-json <json>', 'Raw evaluation result JSON object', parseResultJson)
|
||||
.option('--json', 'Output JSON envelope')
|
||||
.action(async (options: RunTopicReportResultOptions) =>
|
||||
executeCommand(
|
||||
options,
|
||||
async () => {
|
||||
const client = await getTrpcClient();
|
||||
return client.agentEvalExternal.runTopicReportResult.mutate({
|
||||
correct: options.correct,
|
||||
result: options.resultJson,
|
||||
runId: options.runId,
|
||||
score: options.score,
|
||||
threadId: options.threadId,
|
||||
topicId: options.topicId,
|
||||
});
|
||||
.action(
|
||||
async (
|
||||
options: JsonOption & {
|
||||
correct: boolean;
|
||||
resultJson: Record<string, unknown>;
|
||||
runId: string;
|
||||
score: number;
|
||||
threadId?: string;
|
||||
topicId: string;
|
||||
},
|
||||
`Reported result for topic ${pc.bold(options.topicId)}`,
|
||||
),
|
||||
) =>
|
||||
executeCommand(
|
||||
options,
|
||||
async () => {
|
||||
const client = await getTrpcClient();
|
||||
return client.agentEvalExternal.runTopicReportResult.mutate({
|
||||
correct: options.correct,
|
||||
result: options.resultJson,
|
||||
runId: options.runId,
|
||||
score: options.score,
|
||||
threadId: options.threadId,
|
||||
topicId: options.topicId,
|
||||
});
|
||||
},
|
||||
`Reported result for topic ${pc.bold(options.topicId)}`,
|
||||
),
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// Eval Thread Operations (external eval API)
|
||||
// ============================================
|
||||
evalCmd
|
||||
.command('thread')
|
||||
.description('Manage evaluation threads')
|
||||
.command('list')
|
||||
.description('List threads by topic')
|
||||
.requiredOption('--topic-id <id>', 'Topic ID')
|
||||
.option('--json', 'Output JSON envelope')
|
||||
.action(async (options: JsonOption & { topicId: string }) =>
|
||||
executeCommand(options, async () => {
|
||||
const client = await getTrpcClient();
|
||||
return client.agentEvalExternal.threadsList.query({ topicId: options.topicId });
|
||||
}),
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// Eval Message Operations (external eval API)
|
||||
// ============================================
|
||||
evalCmd
|
||||
.command('message')
|
||||
.description('Manage evaluation messages')
|
||||
.command('list')
|
||||
.description('List messages by topic and optional thread')
|
||||
.requiredOption('--topic-id <id>', 'Topic ID')
|
||||
.option('--thread-id <id>', 'Thread ID')
|
||||
.option('--json', 'Output JSON envelope')
|
||||
.action(async (options: JsonOption & { threadId?: string; topicId: string }) =>
|
||||
executeCommand(options, async () => {
|
||||
const client = await getTrpcClient();
|
||||
return client.agentEvalExternal.messagesList.query({
|
||||
threadId: options.threadId,
|
||||
topicId: options.topicId,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,11 +7,15 @@ import { registerFileCommand } from './file';
|
||||
const { mockTrpcClient } = vi.hoisted(() => ({
|
||||
mockTrpcClient: {
|
||||
file: {
|
||||
checkFileHash: { mutate: vi.fn() },
|
||||
createFile: { mutate: vi.fn() },
|
||||
getFileItemById: { query: vi.fn() },
|
||||
getFiles: { query: vi.fn() },
|
||||
getKnowledgeItems: { query: vi.fn() },
|
||||
recentFiles: { query: vi.fn() },
|
||||
removeFile: { mutate: vi.fn() },
|
||||
removeFiles: { mutate: vi.fn() },
|
||||
updateFile: { mutate: vi.fn() },
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -152,6 +156,105 @@ describe('file command', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('upload', () => {
|
||||
it('should upload file by URL', async () => {
|
||||
mockTrpcClient.file.checkFileHash.mutate.mockResolvedValue({ isExist: false });
|
||||
mockTrpcClient.file.createFile.mutate.mockResolvedValue({
|
||||
id: 'f-new',
|
||||
url: 'https://cdn.example.com/f-new',
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'file',
|
||||
'upload',
|
||||
'https://example.com/doc.pdf',
|
||||
'--hash',
|
||||
'abc123',
|
||||
'--name',
|
||||
'doc.pdf',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.file.checkFileHash.mutate).toHaveBeenCalledWith({ hash: 'abc123' });
|
||||
expect(mockTrpcClient.file.createFile.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: 'https://example.com/doc.pdf',
|
||||
name: 'doc.pdf',
|
||||
hash: 'abc123',
|
||||
}),
|
||||
);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('File created'));
|
||||
});
|
||||
|
||||
it('should skip upload when hash exists', async () => {
|
||||
mockTrpcClient.file.checkFileHash.mutate.mockResolvedValue({ isExist: true });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'file',
|
||||
'upload',
|
||||
'https://example.com/doc.pdf',
|
||||
'--hash',
|
||||
'abc123',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.file.createFile.mutate).not.toHaveBeenCalled();
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('already exists'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('edit', () => {
|
||||
it('should update file parent', async () => {
|
||||
mockTrpcClient.file.updateFile.mutate.mockResolvedValue({ success: true });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'file', 'edit', 'f1', '--parent-id', 'folder1']);
|
||||
|
||||
expect(mockTrpcClient.file.updateFile.mutate).toHaveBeenCalledWith({
|
||||
id: 'f1',
|
||||
parentId: 'folder1',
|
||||
});
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Updated file'));
|
||||
});
|
||||
|
||||
it('should error when no changes specified', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'file', 'edit', 'f1']);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('No changes'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('kb-items', () => {
|
||||
it('should list knowledge items for a file', async () => {
|
||||
mockTrpcClient.file.getKnowledgeItems.query.mockResolvedValue({
|
||||
items: [{ id: 'ki1', name: 'Item 1', type: 'chunk' }],
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'file', 'kb-items', 'f1']);
|
||||
|
||||
expect(mockTrpcClient.file.getKnowledgeItems.query).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ fileId: 'f1' }),
|
||||
);
|
||||
expect(consoleSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should show empty message', async () => {
|
||||
mockTrpcClient.file.getKnowledgeItems.query.mockResolvedValue({ items: [] });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'file', 'kb-items', 'f1']);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith('No knowledge items found.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('recent', () => {
|
||||
it('should list recent files', async () => {
|
||||
mockTrpcClient.file.recentFiles.query.mockResolvedValue([
|
||||
|
||||
@@ -110,6 +110,117 @@ export function registerFileCommand(program: Command) {
|
||||
console.log(`${pc.green('✓')} Deleted ${ids.length} file(s)`);
|
||||
});
|
||||
|
||||
// ── upload ───────────────────────────────────────────
|
||||
|
||||
file
|
||||
.command('upload <url>')
|
||||
.description('Upload a file by URL (checks hash first)')
|
||||
.option('--hash <hash>', 'File hash for deduplication check')
|
||||
.option('--name <name>', 'File name')
|
||||
.option('--type <type>', 'File MIME type')
|
||||
.option('--size <size>', 'File size in bytes')
|
||||
.option('--parent-id <id>', 'Parent folder ID')
|
||||
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
|
||||
.action(
|
||||
async (
|
||||
url: string,
|
||||
options: {
|
||||
hash?: string;
|
||||
json?: string | boolean;
|
||||
name?: string;
|
||||
parentId?: string;
|
||||
size?: string;
|
||||
type?: string;
|
||||
},
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
// Check hash first if provided
|
||||
if (options.hash) {
|
||||
const check = await client.file.checkFileHash.mutate({ hash: options.hash });
|
||||
if ((check as any)?.isExist) {
|
||||
console.log(`${pc.yellow('!')} File with this hash already exists.`);
|
||||
if (options.json !== undefined) {
|
||||
outputJson(check);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const input: Record<string, any> = { url };
|
||||
if (options.name) input.name = options.name;
|
||||
if (options.type) input.fileType = options.type;
|
||||
if (options.size) input.size = Number.parseInt(options.size, 10);
|
||||
if (options.hash) input.hash = options.hash;
|
||||
if (options.parentId) input.parentId = options.parentId;
|
||||
|
||||
const result = await client.file.createFile.mutate(input as any);
|
||||
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(result, fields);
|
||||
return;
|
||||
}
|
||||
|
||||
const r = result as any;
|
||||
console.log(`${pc.green('✓')} File created: ${pc.bold(r.id || '')}`);
|
||||
if (r.url) console.log(` URL: ${pc.dim(r.url)}`);
|
||||
},
|
||||
);
|
||||
|
||||
// ── edit ─────────────────────────────────────────────
|
||||
|
||||
file
|
||||
.command('edit <id>')
|
||||
.description('Update file info (e.g. move to folder)')
|
||||
.option('--parent-id <id>', 'Move file to a folder (use "null" to unset)')
|
||||
.action(async (id: string, options: { parentId?: string }) => {
|
||||
if (!options.parentId) {
|
||||
log.error('No changes specified. Use --parent-id.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
const parentId = options.parentId === 'null' ? null : options.parentId;
|
||||
await client.file.updateFile.mutate({ id, parentId } as any);
|
||||
console.log(`${pc.green('✓')} Updated file ${pc.bold(id)}`);
|
||||
});
|
||||
|
||||
// ── kb-items ────────────────────────────────────────
|
||||
|
||||
file
|
||||
.command('kb-items <id>')
|
||||
.description('View knowledge base items associated with a file')
|
||||
.option('-L, --limit <n>', 'Maximum number of items', '30')
|
||||
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
|
||||
.action(async (id: string, options: { json?: string | boolean; limit?: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
const input: any = { fileId: id };
|
||||
if (options.limit) input.limit = Number.parseInt(options.limit, 10);
|
||||
|
||||
const result = await client.file.getKnowledgeItems.query(input);
|
||||
const items = Array.isArray(result) ? result : ((result as any).items ?? []);
|
||||
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(items, fields);
|
||||
return;
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
console.log('No knowledge items found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = items.map((item: any) => [
|
||||
item.id || '',
|
||||
truncate(item.name || item.text || '', 60),
|
||||
item.type || '',
|
||||
]);
|
||||
|
||||
printTable(rows, ['ID', 'CONTENT', 'TYPE']);
|
||||
});
|
||||
|
||||
// ── recent ────────────────────────────────────────────
|
||||
|
||||
file
|
||||
|
||||
@@ -7,6 +7,7 @@ import { registerGenerateCommand } from './generate';
|
||||
const { mockTrpcClient } = vi.hoisted(() => ({
|
||||
mockTrpcClient: {
|
||||
generation: {
|
||||
deleteGeneration: { mutate: vi.fn() },
|
||||
getGenerationStatus: { query: vi.fn() },
|
||||
},
|
||||
generationTopic: {
|
||||
@@ -329,6 +330,20 @@ describe('generate command', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete a generation with --yes', async () => {
|
||||
mockTrpcClient.generation.deleteGeneration.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'generate', 'delete', 'gen-1', '--yes']);
|
||||
|
||||
expect(mockTrpcClient.generation.deleteGeneration.mutate).toHaveBeenCalledWith({
|
||||
generationId: 'gen-1',
|
||||
});
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Deleted generation'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('status', () => {
|
||||
it('should show generation status', async () => {
|
||||
mockTrpcClient.generation.getGenerationStatus.query.mockResolvedValue({
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../../api/client';
|
||||
import { outputJson, printTable, timeAgo, truncate } from '../../utils/format';
|
||||
import { confirm, outputJson, printTable, timeAgo, truncate } from '../../utils/format';
|
||||
import { registerAsrCommand } from './asr';
|
||||
import { registerImageCommand } from './image';
|
||||
import { registerTextCommand } from './text';
|
||||
@@ -51,6 +51,111 @@ export function registerGenerateCommand(program: Command) {
|
||||
}
|
||||
});
|
||||
|
||||
// ── download ──────────────────────────────────────────
|
||||
generate
|
||||
.command('download <generationId> <taskId>')
|
||||
.description('Wait for generation to complete and download the result')
|
||||
.option('-o, --output <path>', 'Output file path (default: auto-detect from asset)')
|
||||
.option('--interval <sec>', 'Polling interval in seconds', '5')
|
||||
.option('--timeout <sec>', 'Timeout in seconds (0 = no timeout)', '300')
|
||||
.action(
|
||||
async (
|
||||
generationId: string,
|
||||
taskId: string,
|
||||
options: { interval?: string; output?: string; timeout?: string },
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
const interval = Number.parseInt(options.interval || '5', 10) * 1000;
|
||||
const timeout = Number.parseInt(options.timeout || '300', 10) * 1000;
|
||||
const startTime = Date.now();
|
||||
|
||||
console.log(`${pc.yellow('⋯')} Waiting for generation ${pc.bold(generationId)}...`);
|
||||
|
||||
// Poll for completion
|
||||
while (true) {
|
||||
const result = (await client.generation.getGenerationStatus.query({
|
||||
asyncTaskId: taskId,
|
||||
generationId,
|
||||
})) as any;
|
||||
|
||||
if (result.status === 'success' && result.generation) {
|
||||
const gen = result.generation;
|
||||
const url = gen.asset?.url;
|
||||
|
||||
if (!url) {
|
||||
console.log(`${pc.red('✗')} Generation succeeded but no asset URL found.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Determine output path
|
||||
const ext = url.split('?')[0].split('.').pop() || 'bin';
|
||||
const outputPath = options.output || `${generationId}.${ext}`;
|
||||
|
||||
console.log(`${pc.green('✓')} Generation complete. Downloading...`);
|
||||
|
||||
// Download
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
console.log(`${pc.red('✗')} Download failed: ${res.status} ${res.statusText}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { writeFile } = await import('node:fs/promises');
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
await writeFile(outputPath, buffer);
|
||||
|
||||
console.log(
|
||||
`${pc.green('✓')} Saved to ${pc.bold(outputPath)} (${(buffer.length / 1024).toFixed(1)} KB)`,
|
||||
);
|
||||
if (gen.asset?.thumbnailUrl) {
|
||||
console.log(` Thumbnail: ${pc.dim(gen.asset.thumbnailUrl)}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.status === 'error') {
|
||||
const errMsg =
|
||||
result.error?.body?.detail || result.error?.message || JSON.stringify(result.error);
|
||||
console.log(`${pc.red('✗')} Generation failed: ${errMsg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Check timeout
|
||||
if (timeout > 0 && Date.now() - startTime > timeout) {
|
||||
console.log(
|
||||
`${pc.red('✗')} Timed out after ${options.timeout}s. Task still ${result.status}.`,
|
||||
);
|
||||
console.log(pc.dim(`Run "lh gen status ${generationId} ${taskId}" to check later.`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
`\r${pc.yellow('⋯')} Status: ${colorStatus(result.status)}... (${Math.round((Date.now() - startTime) / 1000)}s)`,
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, interval));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ── delete ─────────────────────────────────────────
|
||||
generate
|
||||
.command('delete <generationId>')
|
||||
.description('Delete a generation record')
|
||||
.option('--yes', 'Skip confirmation prompt')
|
||||
.action(async (generationId: string, options: { yes?: boolean }) => {
|
||||
if (!options.yes) {
|
||||
const confirmed = await confirm('Are you sure you want to delete this generation?');
|
||||
if (!confirmed) {
|
||||
console.log('Cancelled.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
await client.generation.deleteGeneration.mutate({ generationId });
|
||||
console.log(`${pc.green('✓')} Deleted generation ${pc.bold(generationId)}`);
|
||||
});
|
||||
|
||||
// ── list ────────────────────────────────────────────
|
||||
generate
|
||||
.command('list')
|
||||
|
||||
@@ -58,6 +58,9 @@ export function registerTextCommand(parent: Command) {
|
||||
const payload: Record<string, any> = {
|
||||
messages,
|
||||
model,
|
||||
// For non-streaming, use responseMode 'json' to get a plain JSON response
|
||||
// instead of SSE (the backend converts non-stream to SSE by default)
|
||||
responseMode: useStream ? 'stream' : 'json',
|
||||
stream: useStream,
|
||||
};
|
||||
if (options.temperature) payload.temperature = Number.parseFloat(options.temperature);
|
||||
@@ -83,7 +86,12 @@ export function registerTextCommand(parent: Command) {
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(body, null, 2));
|
||||
} else {
|
||||
const content = (body as any).choices?.[0]?.message?.content || JSON.stringify(body);
|
||||
// Support both OpenAI format (choices[].message.content) and
|
||||
// Anthropic format (content[].text)
|
||||
const content =
|
||||
(body as any).choices?.[0]?.message?.content ||
|
||||
(body as any).content?.[0]?.text ||
|
||||
JSON.stringify(body);
|
||||
process.stdout.write(content);
|
||||
process.stdout.write('\n');
|
||||
}
|
||||
@@ -128,9 +136,12 @@ async function streamSSEResponse(body: ReadableStream<Uint8Array>, json?: boolea
|
||||
const parsed = JSON.parse(data);
|
||||
if (json) {
|
||||
console.log(JSON.stringify(parsed));
|
||||
} else {
|
||||
const content = parsed.choices?.[0]?.delta?.content;
|
||||
if (content) process.stdout.write(content);
|
||||
} else if (typeof parsed === 'string' && parsed !== 'stop') {
|
||||
// LobeHub SSE sends content as JSON strings: "Hello", "world"
|
||||
process.stdout.write(parsed);
|
||||
} else if (parsed?.choices?.[0]?.delta?.content) {
|
||||
// Standard OpenAI SSE format
|
||||
process.stdout.write(parsed.choices[0].delta.content);
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, might be raw text chunk
|
||||
|
||||
@@ -51,12 +51,20 @@ export function registerVideoCommand(parent: Command) {
|
||||
|
||||
const data = r.data || r;
|
||||
console.log(`${pc.green('✓')} Video generation started`);
|
||||
if (data.generationId) {
|
||||
console.log(` Generation ID: ${pc.bold(data.generationId)}`);
|
||||
if (data.batch?.id) console.log(` Batch ID: ${pc.bold(data.batch.id)}`);
|
||||
|
||||
const generations = data.generations || [];
|
||||
if (generations.length > 0) {
|
||||
for (const gen of generations) {
|
||||
if (gen.asyncTaskId) {
|
||||
console.log(` Generation ${pc.bold(gen.id)} → Task ${pc.dim(gen.asyncTaskId)}`);
|
||||
}
|
||||
}
|
||||
console.log();
|
||||
console.log(
|
||||
pc.dim('Use "lh generate status <generationId> <taskId>" to check progress.'),
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
pc.dim('Video generation runs asynchronously. Check status or wait for notification.'),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@ import { registerKbCommand } from './kb';
|
||||
|
||||
const { mockTrpcClient } = vi.hoisted(() => ({
|
||||
mockTrpcClient: {
|
||||
file: {
|
||||
getFiles: { query: vi.fn() },
|
||||
getKnowledgeItems: { query: vi.fn() },
|
||||
},
|
||||
knowledgeBase: {
|
||||
addFilesToKnowledgeBase: { mutate: vi.fn() },
|
||||
createKnowledgeBase: { mutate: vi.fn() },
|
||||
@@ -44,6 +48,9 @@ describe('kb command', () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Default: file queries return empty
|
||||
mockTrpcClient.file.getFiles.query.mockResolvedValue([]);
|
||||
mockTrpcClient.file.getKnowledgeItems.query.mockResolvedValue({ hasMore: false, items: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -121,7 +128,7 @@ describe('kb command', () => {
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a knowledge base', async () => {
|
||||
mockTrpcClient.knowledgeBase.createKnowledgeBase.mutate.mockResolvedValue({ id: 'kb-new' });
|
||||
mockTrpcClient.knowledgeBase.createKnowledgeBase.mutate.mockResolvedValue('kb-new');
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
|
||||
+224
-16
@@ -1,12 +1,38 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../api/client';
|
||||
import { getAuthInfo } from '../api/http';
|
||||
import { confirm, outputJson, printTable, timeAgo, truncate } from '../utils/format';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
function formatFileType(fileType: string): string {
|
||||
if (!fileType) return '';
|
||||
// Simplify common MIME types to readable short names
|
||||
const map: Record<string, string> = {
|
||||
'application/msword': 'doc',
|
||||
'application/pdf': 'pdf',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
|
||||
'custom/folder': 'folder',
|
||||
'text/markdown': 'md',
|
||||
'text/plain': 'txt',
|
||||
};
|
||||
if (map[fileType]) return map[fileType];
|
||||
// For other types, extract subtype (e.g. "image/png" → "png")
|
||||
const parts = fileType.split('/');
|
||||
return parts.length > 1 ? parts[1] : fileType;
|
||||
}
|
||||
|
||||
export function registerKbCommand(program: Command) {
|
||||
const kb = program.command('kb').description('Manage knowledge bases');
|
||||
const kb = program
|
||||
.command('kb')
|
||||
.description('Manage knowledge bases, folders, documents, and files');
|
||||
|
||||
// ── list ──────────────────────────────────────────────
|
||||
|
||||
@@ -54,9 +80,40 @@ export function registerKbCommand(program: Command) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Recursively fetch all items in the knowledge base (with pagination)
|
||||
const allItems: any[] = [];
|
||||
async function fetchItems(parentId: string | null, depth = 0) {
|
||||
const PAGE_SIZE = 100;
|
||||
let offset = 0;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
const query: any = { knowledgeBaseId: id, limit: PAGE_SIZE, offset, parentId };
|
||||
const result = await client.file.getKnowledgeItems.query(query);
|
||||
const list = Array.isArray(result) ? result : ((result as any).items ?? []);
|
||||
hasMore = Array.isArray(result) ? false : ((result as any).hasMore ?? false);
|
||||
offset += list.length;
|
||||
|
||||
// Collect folders for parallel recursive fetch
|
||||
const folders: any[] = [];
|
||||
for (const item of list) {
|
||||
allItems.push({ ...item, _depth: depth });
|
||||
if (item.fileType === 'custom/folder') {
|
||||
folders.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch all sub-folders in parallel
|
||||
if (folders.length > 0) {
|
||||
await Promise.all(folders.map((f) => fetchItems(f.id, depth + 1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
await fetchItems(null);
|
||||
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(result, fields);
|
||||
outputJson({ ...result, files: allItems }, fields);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -66,19 +123,23 @@ export function registerKbCommand(program: Command) {
|
||||
if ((result as any).updatedAt) meta.push(`Updated ${timeAgo((result as any).updatedAt)}`);
|
||||
if (meta.length > 0) console.log(pc.dim(meta.join(' · ')));
|
||||
|
||||
// Show files if available
|
||||
if ((result as any).files && Array.isArray((result as any).files)) {
|
||||
const files = (result as any).files;
|
||||
if (files.length > 0) {
|
||||
console.log();
|
||||
console.log(pc.bold(`Files (${files.length}):`));
|
||||
const rows = files.map((f: any) => [
|
||||
if (allItems.length > 0) {
|
||||
console.log();
|
||||
console.log(pc.bold(`Items (${allItems.length}):`));
|
||||
const rows = allItems.map((f: any) => {
|
||||
const indent = ' '.repeat(f._depth);
|
||||
const name = f.name || f.filename || '';
|
||||
return [
|
||||
f.id,
|
||||
truncate(f.name || f.filename || '', 50),
|
||||
f.fileType || '',
|
||||
]);
|
||||
printTable(rows, ['ID', 'NAME', 'TYPE']);
|
||||
}
|
||||
f.sourceType === 'document' ? 'Doc' : 'File',
|
||||
truncate(`${indent}${name}`, 45),
|
||||
formatFileType(f.fileType || ''),
|
||||
f.size ? `${Math.round(f.size / 1024)}KB` : '',
|
||||
];
|
||||
});
|
||||
printTable(rows, ['ID', 'SOURCE', 'NAME', 'TYPE', 'SIZE']);
|
||||
} else {
|
||||
console.log(pc.dim('\nNo files in this knowledge base.'));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -98,8 +159,8 @@ export function registerKbCommand(program: Command) {
|
||||
if (options.description) input.description = options.description;
|
||||
if (options.avatar) input.avatar = options.avatar;
|
||||
|
||||
const result = await client.knowledgeBase.createKnowledgeBase.mutate(input);
|
||||
console.log(`${pc.green('✓')} Created knowledge base ${pc.bold((result as any).id)}`);
|
||||
const id = await client.knowledgeBase.createKnowledgeBase.mutate(input);
|
||||
console.log(`${pc.green('✓')} Created knowledge base ${pc.bold(String(id))}`);
|
||||
});
|
||||
|
||||
// ── edit ──────────────────────────────────────────────
|
||||
@@ -193,4 +254,151 @@ export function registerKbCommand(program: Command) {
|
||||
`${pc.green('✓')} Removed ${options.ids.length} file(s) from knowledge base ${pc.bold(knowledgeBaseId)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// ── mkdir ───────────────────────────────────────────
|
||||
|
||||
kb.command('mkdir <knowledgeBaseId>')
|
||||
.description('Create a folder in a knowledge base')
|
||||
.requiredOption('-n, --name <name>', 'Folder name')
|
||||
.option('--parent <parentId>', 'Parent folder ID')
|
||||
.action(async (knowledgeBaseId: string, options: { name: string; parent?: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.document.createDocument.mutate({
|
||||
editorData: JSON.stringify({}),
|
||||
fileType: 'custom/folder',
|
||||
knowledgeBaseId,
|
||||
parentId: options.parent,
|
||||
title: options.name,
|
||||
});
|
||||
console.log(`${pc.green('✓')} Created folder ${pc.bold((result as any).id)}`);
|
||||
});
|
||||
|
||||
// ── create-doc ──────────────────────────────────────
|
||||
|
||||
kb.command('create-doc <knowledgeBaseId>')
|
||||
.description('Create a document in a knowledge base')
|
||||
.requiredOption('-t, --title <title>', 'Document title')
|
||||
.option('-c, --content <content>', 'Document content (text)')
|
||||
.option('--parent <parentId>', 'Parent folder ID')
|
||||
.action(
|
||||
async (
|
||||
knowledgeBaseId: string,
|
||||
options: { content?: string; parent?: string; title: string },
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.document.createDocument.mutate({
|
||||
content: options.content,
|
||||
editorData: JSON.stringify({}),
|
||||
fileType: 'custom/document',
|
||||
knowledgeBaseId,
|
||||
parentId: options.parent,
|
||||
title: options.title,
|
||||
});
|
||||
console.log(`${pc.green('✓')} Created document ${pc.bold((result as any).id)}`);
|
||||
},
|
||||
);
|
||||
|
||||
// ── move ────────────────────────────────────────────
|
||||
|
||||
kb.command('move <id>')
|
||||
.description('Move a file or document to a different folder')
|
||||
.option('--parent <parentId>', 'Target folder ID (omit to move to root)')
|
||||
.option('--type <type>', 'Item type: file or doc', 'file')
|
||||
.action(async (id: string, options: { parent?: string; type: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
const parentId = options.parent ?? null;
|
||||
|
||||
if (options.type === 'doc') {
|
||||
await client.document.updateDocument.mutate({ id, parentId });
|
||||
} else {
|
||||
await client.file.updateFile.mutate({ id, parentId });
|
||||
}
|
||||
|
||||
const dest = parentId ? `folder ${pc.bold(parentId)}` : 'root';
|
||||
console.log(`${pc.green('✓')} Moved ${pc.bold(id)} to ${dest}`);
|
||||
});
|
||||
|
||||
// ── upload ──────────────────────────────────────────
|
||||
|
||||
kb.command('upload <knowledgeBaseId> <filePath>')
|
||||
.description('Upload a file to a knowledge base')
|
||||
.option('--parent <parentId>', 'Parent folder ID')
|
||||
.action(async (knowledgeBaseId: string, filePath: string, options: { parent?: string }) => {
|
||||
const resolved = path.resolve(filePath);
|
||||
if (!fs.existsSync(resolved)) {
|
||||
log.error(`File not found: ${resolved}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const stat = fs.statSync(resolved);
|
||||
const fileName = path.basename(resolved);
|
||||
const fileBuffer = fs.readFileSync(resolved);
|
||||
|
||||
// Compute SHA-256 hash
|
||||
const hash = crypto.createHash('sha256').update(fileBuffer).digest('hex');
|
||||
|
||||
// Detect MIME type from extension
|
||||
const ext = path.extname(fileName).toLowerCase().slice(1);
|
||||
const mimeMap: Record<string, string> = {
|
||||
csv: 'text/csv',
|
||||
doc: 'application/msword',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
gif: 'image/gif',
|
||||
jpeg: 'image/jpeg',
|
||||
jpg: 'image/jpeg',
|
||||
json: 'application/json',
|
||||
md: 'text/markdown',
|
||||
mp3: 'audio/mpeg',
|
||||
mp4: 'video/mp4',
|
||||
pdf: 'application/pdf',
|
||||
png: 'image/png',
|
||||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
svg: 'image/svg+xml',
|
||||
txt: 'text/plain',
|
||||
webp: 'image/webp',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
};
|
||||
const fileType = mimeMap[ext] || 'application/octet-stream';
|
||||
|
||||
const client = await getTrpcClient();
|
||||
const { serverUrl, headers } = await getAuthInfo();
|
||||
|
||||
// 1. Get presigned URL
|
||||
const date = new Date().toLocaleDateString('en-CA'); // YYYY-MM-DD
|
||||
const pathname = `files/${date}/${hash}.${ext}`;
|
||||
const presigned = await client.upload.createS3PreSignedUrl.mutate({ pathname });
|
||||
|
||||
// 2. Upload to S3
|
||||
const presignedUrl = typeof presigned === 'string' ? presigned : (presigned as any).url;
|
||||
const uploadRes = await fetch(presignedUrl, {
|
||||
body: fileBuffer,
|
||||
headers: { 'Content-Type': fileType },
|
||||
method: 'PUT',
|
||||
});
|
||||
if (!uploadRes.ok) {
|
||||
log.error(`Upload failed: ${uploadRes.status} ${uploadRes.statusText}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 3. Create file record
|
||||
const result = await client.file.createFile.mutate({
|
||||
fileType,
|
||||
hash,
|
||||
knowledgeBaseId,
|
||||
metadata: {
|
||||
date,
|
||||
dirname: '',
|
||||
filename: fileName,
|
||||
path: pathname,
|
||||
},
|
||||
name: fileName,
|
||||
parentId: options.parent,
|
||||
size: stat.size,
|
||||
url: pathname,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`${pc.green('✓')} Uploaded ${pc.bold(fileName)} → ${pc.bold((result as any).id)}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -35,21 +35,8 @@ export function registerMemoryCommand(program: Command) {
|
||||
const allResults: Record<string, any[]> = {};
|
||||
|
||||
for (const cat of categoriesToFetch) {
|
||||
const getter = `get${capitalize(cat)}` as string;
|
||||
const getterPlural = `${getter}s` as string;
|
||||
|
||||
// Try plural first (getIdentities, getActivities, etc.), then singular
|
||||
const router = client.userMemory as any;
|
||||
try {
|
||||
if (router[getterPlural]) {
|
||||
allResults[cat] = await router[getterPlural].query();
|
||||
} else if (router[getter]) {
|
||||
allResults[cat] = await router[getter].query();
|
||||
} else {
|
||||
// Try the special name patterns
|
||||
const items = await fetchCategory(client, cat);
|
||||
allResults[cat] = items;
|
||||
}
|
||||
allResults[cat] = await fetchCategory(client, cat);
|
||||
} catch {
|
||||
allResults[cat] = [];
|
||||
}
|
||||
@@ -112,8 +99,11 @@ export function registerMemoryCommand(program: Command) {
|
||||
|
||||
try {
|
||||
const result = await (client.userMemory as any).createIdentity.mutate(input);
|
||||
const id = result?.id || 'unknown';
|
||||
console.log(`${pc.green('✓')} Created identity memory ${pc.bold(id)}`);
|
||||
const memoryId = result?.userMemoryId || 'unknown';
|
||||
const identityId = result?.identityId || 'unknown';
|
||||
console.log(
|
||||
`${pc.green('✓')} Created identity memory ${pc.bold(memoryId)} (identity: ${pc.bold(identityId)})`,
|
||||
);
|
||||
} catch (error: any) {
|
||||
log.error(`Failed to create identity: ${error.message}`);
|
||||
process.exit(1);
|
||||
|
||||
@@ -9,6 +9,7 @@ const { mockTrpcClient } = vi.hoisted(() => ({
|
||||
count: { query: vi.fn() },
|
||||
getHeatmaps: { query: vi.fn() },
|
||||
getMessages: { query: vi.fn() },
|
||||
listAll: { query: vi.fn() },
|
||||
removeMessage: { mutate: vi.fn() },
|
||||
removeMessages: { mutate: vi.fn() },
|
||||
searchMessages: { query: vi.fn() },
|
||||
@@ -54,18 +55,20 @@ describe('message command', () => {
|
||||
}
|
||||
|
||||
describe('list', () => {
|
||||
it('should display messages', async () => {
|
||||
mockTrpcClient.message.getMessages.query.mockResolvedValue([
|
||||
it('should use listAll when no filters', async () => {
|
||||
mockTrpcClient.message.listAll.query.mockResolvedValue([
|
||||
{ content: 'Hello', createdAt: new Date().toISOString(), id: 'm1', role: 'user' },
|
||||
]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'message', 'list']);
|
||||
|
||||
expect(mockTrpcClient.message.listAll.query).toHaveBeenCalled();
|
||||
expect(mockTrpcClient.message.getMessages.query).not.toHaveBeenCalled();
|
||||
expect(consoleSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should filter by topic-id', async () => {
|
||||
it('should filter by topic-id using getMessages', async () => {
|
||||
mockTrpcClient.message.getMessages.query.mockResolvedValue([]);
|
||||
|
||||
const program = createProgram();
|
||||
@@ -74,6 +77,7 @@ describe('message command', () => {
|
||||
expect(mockTrpcClient.message.getMessages.query).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ topicId: 't1' }),
|
||||
);
|
||||
expect(mockTrpcClient.message.listAll.query).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../api/client';
|
||||
import { confirm, outputJson, printTable, timeAgo, truncate } from '../utils/format';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
export function registerMessageCommand(program: Command) {
|
||||
const message = program.command('message').description('Manage messages');
|
||||
@@ -14,9 +15,9 @@ export function registerMessageCommand(program: Command) {
|
||||
.description('List messages')
|
||||
.option('--topic-id <id>', 'Filter by topic ID')
|
||||
.option('--agent-id <id>', 'Filter by agent ID')
|
||||
.option('--session-id <id>', 'Filter by session ID')
|
||||
.option('-L, --limit <n>', 'Page size', '30')
|
||||
.option('--page <n>', 'Page number', '1')
|
||||
.option('--user', 'Only show user messages')
|
||||
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
|
||||
.action(
|
||||
async (options: {
|
||||
@@ -24,20 +25,38 @@ export function registerMessageCommand(program: Command) {
|
||||
json?: string | boolean;
|
||||
limit?: string;
|
||||
page?: string;
|
||||
sessionId?: string;
|
||||
topicId?: string;
|
||||
user?: boolean;
|
||||
}) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
const input: Record<string, any> = {};
|
||||
if (options.topicId) input.topicId = options.topicId;
|
||||
if (options.agentId) input.agentId = options.agentId;
|
||||
if (options.sessionId) input.sessionId = options.sessionId;
|
||||
if (options.limit) input.pageSize = Number.parseInt(options.limit, 10);
|
||||
if (options.page) input.current = Number.parseInt(options.page, 10);
|
||||
const hasFilter = options.topicId || options.agentId;
|
||||
const pageSize = options.limit ? Number.parseInt(options.limit, 10) : undefined;
|
||||
const current = options.page ? Number.parseInt(options.page, 10) : undefined;
|
||||
|
||||
const result = await client.message.getMessages.query(input as any);
|
||||
const items = Array.isArray(result) ? result : ((result as any).items ?? []);
|
||||
let items: any[];
|
||||
|
||||
if (hasFilter) {
|
||||
const input: Record<string, any> = {};
|
||||
if (options.topicId) input.topicId = options.topicId;
|
||||
if (options.agentId) input.agentId = options.agentId;
|
||||
if (pageSize) input.pageSize = pageSize;
|
||||
if (current) input.current = current;
|
||||
|
||||
const result = await client.message.getMessages.query(input as any);
|
||||
items = Array.isArray(result) ? result : ((result as any).items ?? []);
|
||||
} else {
|
||||
const input: Record<string, any> = {};
|
||||
if (pageSize) input.pageSize = pageSize;
|
||||
if (current) input.current = current;
|
||||
|
||||
const result = await client.message.listAll.query(input as any);
|
||||
items = Array.isArray(result) ? result : [];
|
||||
}
|
||||
|
||||
if (options.user) {
|
||||
items = items.filter((m: any) => m.role === 'user');
|
||||
}
|
||||
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
@@ -141,6 +160,198 @@ export function registerMessageCommand(program: Command) {
|
||||
console.log(`Messages: ${pc.bold(String(count))}`);
|
||||
});
|
||||
|
||||
// ── create ────────────────────────────────────────────
|
||||
|
||||
message
|
||||
.command('create')
|
||||
.description('Create a message')
|
||||
.requiredOption('-r, --role <role>', 'Message role (user, assistant, system)')
|
||||
.requiredOption('-c, --content <content>', 'Message content')
|
||||
.option('--agent-id <id>', 'Agent ID')
|
||||
.option('--topic-id <id>', 'Topic ID')
|
||||
.option('--session-id <id>', 'Session ID')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(
|
||||
async (options: {
|
||||
agentId?: string;
|
||||
content: string;
|
||||
json?: boolean;
|
||||
role: string;
|
||||
sessionId?: string;
|
||||
topicId?: string;
|
||||
}) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
const input: Record<string, any> = {
|
||||
content: options.content,
|
||||
role: options.role,
|
||||
};
|
||||
if (options.agentId) input.agentId = options.agentId;
|
||||
if (options.topicId) input.topicId = options.topicId;
|
||||
if (options.sessionId) input.sessionId = options.sessionId;
|
||||
|
||||
const result = await client.message.createMessage.mutate(input as any);
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const r = result as any;
|
||||
console.log(`${pc.green('✓')} Created message ${pc.bold(r.id || '')}`);
|
||||
},
|
||||
);
|
||||
|
||||
// ── edit ────────────────────────────────────────────
|
||||
|
||||
message
|
||||
.command('edit <id>')
|
||||
.description('Update a message')
|
||||
.option('-c, --content <content>', 'New content')
|
||||
.option('--role <role>', 'New role')
|
||||
.action(async (id: string, options: { content?: string; role?: string }) => {
|
||||
const value: Record<string, any> = {};
|
||||
if (options.content) value.content = options.content;
|
||||
if (options.role) value.role = options.role;
|
||||
|
||||
if (Object.keys(value).length === 0) {
|
||||
log.error('No changes specified. Use --content or --role.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
await client.message.update.mutate({ id, value } as any);
|
||||
console.log(`${pc.green('✓')} Updated message ${pc.bold(id)}`);
|
||||
});
|
||||
|
||||
// ── add-files ───────────────────────────────────────
|
||||
|
||||
message
|
||||
.command('add-files <id>')
|
||||
.description('Add files to a message')
|
||||
.requiredOption('--file-ids <ids>', 'Comma-separated file IDs')
|
||||
.action(async (id: string, options: { fileIds: string }) => {
|
||||
const fileIds = options.fileIds.split(',').map((s) => s.trim());
|
||||
|
||||
const client = await getTrpcClient();
|
||||
await client.message.addFilesToMessage.mutate({ fileIds, id } as any);
|
||||
console.log(`${pc.green('✓')} Added ${fileIds.length} file(s) to message ${pc.bold(id)}`);
|
||||
});
|
||||
|
||||
// ── word-count ──────────────────────────────────────
|
||||
|
||||
message
|
||||
.command('word-count')
|
||||
.description('Count total words in messages')
|
||||
.option('--start <date>', 'Start date (ISO format)')
|
||||
.option('--end <date>', 'End date (ISO format)')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(async (options: { end?: string; json?: boolean; start?: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
const input: Record<string, any> = {};
|
||||
if (options.start) input.startDate = options.start;
|
||||
if (options.end) input.endDate = options.end;
|
||||
|
||||
const count = await client.message.countWords.query(input as any);
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({ wordCount: count }));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Word count: ${pc.bold(String(count))}`);
|
||||
});
|
||||
|
||||
// ── rank-models ─────────────────────────────────────
|
||||
|
||||
message
|
||||
.command('rank-models')
|
||||
.description('Rank models by message usage')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(async (options: { json?: boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.message.rankModels.query();
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const items = Array.isArray(result) ? result : [];
|
||||
if (items.length === 0) {
|
||||
console.log('No model usage data.');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = items.map((m: any) => [m.id || m.model || '', String(m.count || 0)]);
|
||||
printTable(rows, ['MODEL', 'COUNT']);
|
||||
});
|
||||
|
||||
// ── delete-by-assistant ─────────────────────────────
|
||||
|
||||
message
|
||||
.command('delete-by-assistant')
|
||||
.description('Delete messages by assistant context')
|
||||
.option('--agent-id <id>', 'Agent ID')
|
||||
.option('--session-id <id>', 'Session ID')
|
||||
.option('--topic-id <id>', 'Topic ID')
|
||||
.option('--yes', 'Skip confirmation prompt')
|
||||
.action(
|
||||
async (options: {
|
||||
agentId?: string;
|
||||
sessionId?: string;
|
||||
topicId?: string;
|
||||
yes?: boolean;
|
||||
}) => {
|
||||
if (!options.agentId && !options.sessionId) {
|
||||
log.error('Specify at least --agent-id or --session-id.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!options.yes) {
|
||||
const confirmed = await confirm('Are you sure you want to delete messages by assistant?');
|
||||
if (!confirmed) {
|
||||
console.log('Cancelled.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
const input: Record<string, any> = {};
|
||||
if (options.agentId) input.agentId = options.agentId;
|
||||
if (options.sessionId) input.sessionId = options.sessionId;
|
||||
if (options.topicId) input.topicId = options.topicId;
|
||||
|
||||
await client.message.removeMessagesByAssistant.mutate(input as any);
|
||||
console.log(`${pc.green('✓')} Deleted messages by assistant`);
|
||||
},
|
||||
);
|
||||
|
||||
// ── delete-by-group ─────────────────────────────────
|
||||
|
||||
message
|
||||
.command('delete-by-group <groupId>')
|
||||
.description('Delete messages by group')
|
||||
.option('--topic-id <id>', 'Topic ID')
|
||||
.option('--yes', 'Skip confirmation prompt')
|
||||
.action(async (groupId: string, options: { topicId?: string; yes?: boolean }) => {
|
||||
if (!options.yes) {
|
||||
const confirmed = await confirm('Are you sure you want to delete messages by group?');
|
||||
if (!confirmed) {
|
||||
console.log('Cancelled.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
const input: Record<string, any> = { groupId };
|
||||
if (options.topicId) input.topicId = options.topicId;
|
||||
|
||||
await client.message.removeMessagesByGroup.mutate(input as any);
|
||||
console.log(`${pc.green('✓')} Deleted messages for group ${pc.bold(groupId)}`);
|
||||
});
|
||||
|
||||
// ── heatmap ───────────────────────────────────────────
|
||||
|
||||
message
|
||||
|
||||
@@ -7,10 +7,17 @@ import { registerModelCommand } from './model';
|
||||
const { mockTrpcClient } = vi.hoisted(() => ({
|
||||
mockTrpcClient: {
|
||||
aiModel: {
|
||||
batchToggleAiModels: { mutate: vi.fn() },
|
||||
batchUpdateAiModels: { mutate: vi.fn() },
|
||||
clearModelsByProvider: { mutate: vi.fn() },
|
||||
clearRemoteModels: { mutate: vi.fn() },
|
||||
createAiModel: { mutate: vi.fn() },
|
||||
getAiModelById: { query: vi.fn() },
|
||||
getAiProviderModelList: { query: vi.fn() },
|
||||
removeAiModel: { mutate: vi.fn() },
|
||||
toggleModelEnabled: { mutate: vi.fn() },
|
||||
updateAiModel: { mutate: vi.fn() },
|
||||
updateAiModelOrder: { mutate: vi.fn() },
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -66,6 +73,16 @@ describe('model command', () => {
|
||||
);
|
||||
expect(consoleSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should output JSON', async () => {
|
||||
const models = [{ displayName: 'GPT-4', id: 'gpt-4' }];
|
||||
mockTrpcClient.aiModel.getAiProviderModelList.query.mockResolvedValue(models);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'model', 'list', 'openai', '--json']);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(models, null, 2));
|
||||
});
|
||||
});
|
||||
|
||||
describe('view', () => {
|
||||
@@ -93,6 +110,72 @@ describe('model command', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a model', async () => {
|
||||
mockTrpcClient.aiModel.createAiModel.mutate.mockResolvedValue('test-model');
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'model',
|
||||
'create',
|
||||
'--id',
|
||||
'test-model',
|
||||
'--provider',
|
||||
'openai',
|
||||
'--display-name',
|
||||
'Test Model',
|
||||
'--type',
|
||||
'chat',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.aiModel.createAiModel.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'test-model',
|
||||
providerId: 'openai',
|
||||
displayName: 'Test Model',
|
||||
type: 'chat',
|
||||
}),
|
||||
);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Created model'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('edit', () => {
|
||||
it('should update model display name', async () => {
|
||||
mockTrpcClient.aiModel.updateAiModel.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'model',
|
||||
'edit',
|
||||
'gpt-4',
|
||||
'--provider',
|
||||
'openai',
|
||||
'--display-name',
|
||||
'New Name',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.aiModel.updateAiModel.mutate).toHaveBeenCalledWith({
|
||||
id: 'gpt-4',
|
||||
providerId: 'openai',
|
||||
value: expect.objectContaining({ displayName: 'New Name' }),
|
||||
});
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Updated model'));
|
||||
});
|
||||
|
||||
it('should error when no changes specified', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'model', 'edit', 'gpt-4', '--provider', 'openai']);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('No changes'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggle', () => {
|
||||
it('should enable model', async () => {
|
||||
mockTrpcClient.aiModel.toggleModelEnabled.mutate.mockResolvedValue({});
|
||||
@@ -113,6 +196,22 @@ describe('model command', () => {
|
||||
expect.objectContaining({ enabled: true, id: 'gpt-4' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should error when no flag specified', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'model',
|
||||
'toggle',
|
||||
'gpt-4',
|
||||
'--provider',
|
||||
'openai',
|
||||
]);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('--enable or --disable'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
@@ -137,4 +236,160 @@ describe('model command', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('batch-toggle', () => {
|
||||
it('should batch enable models', async () => {
|
||||
mockTrpcClient.aiModel.batchToggleAiModels.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'model',
|
||||
'batch-toggle',
|
||||
'gpt-4',
|
||||
'gpt-3.5',
|
||||
'--provider',
|
||||
'openai',
|
||||
'--enable',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.aiModel.batchToggleAiModels.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enabled: true,
|
||||
id: 'openai',
|
||||
models: ['gpt-4', 'gpt-3.5'],
|
||||
}),
|
||||
);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('2 model(s)'));
|
||||
});
|
||||
|
||||
it('should error when no flag specified', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'model',
|
||||
'batch-toggle',
|
||||
'gpt-4',
|
||||
'--provider',
|
||||
'openai',
|
||||
]);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('--enable or --disable'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('batch-update', () => {
|
||||
it('should batch update models', async () => {
|
||||
mockTrpcClient.aiModel.batchUpdateAiModels.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'model',
|
||||
'batch-update',
|
||||
'openai',
|
||||
'--models',
|
||||
'[{"id":"gpt-4","displayName":"GPT-4 Updated"}]',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.aiModel.batchUpdateAiModels.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'openai',
|
||||
models: [{ id: 'gpt-4', displayName: 'GPT-4 Updated' }],
|
||||
}),
|
||||
);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Batch updated'));
|
||||
});
|
||||
|
||||
it('should reject invalid JSON', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'model',
|
||||
'batch-update',
|
||||
'openai',
|
||||
'--models',
|
||||
'not-json',
|
||||
]);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Invalid models JSON'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sort', () => {
|
||||
it('should update model sort order', async () => {
|
||||
mockTrpcClient.aiModel.updateAiModelOrder.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'model',
|
||||
'sort',
|
||||
'openai',
|
||||
'--sort-map',
|
||||
'[{"id":"gpt-4","sort":0},{"id":"gpt-3.5","sort":1}]',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.aiModel.updateAiModelOrder.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
providerId: 'openai',
|
||||
sortMap: [
|
||||
{ id: 'gpt-4', sort: 0 },
|
||||
{ id: 'gpt-3.5', sort: 1 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Updated sort order'));
|
||||
});
|
||||
|
||||
it('should reject invalid JSON', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'model', 'sort', 'openai', '--sort-map', '{bad}']);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Invalid sort-map JSON'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear', () => {
|
||||
it('should clear all models for provider', async () => {
|
||||
mockTrpcClient.aiModel.clearModelsByProvider.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'model', 'clear', '--provider', 'openai', '--yes']);
|
||||
|
||||
expect(mockTrpcClient.aiModel.clearModelsByProvider.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ providerId: 'openai' }),
|
||||
);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Cleared all models'));
|
||||
});
|
||||
|
||||
it('should clear only remote models', async () => {
|
||||
mockTrpcClient.aiModel.clearRemoteModels.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'model',
|
||||
'clear',
|
||||
'--provider',
|
||||
'openai',
|
||||
'--remote',
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.aiModel.clearRemoteModels.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ providerId: 'openai' }),
|
||||
);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('remote models'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,20 +15,29 @@ export function registerModelCommand(program: Command) {
|
||||
.description('List models for a provider')
|
||||
.option('-L, --limit <n>', 'Maximum number of items', '50')
|
||||
.option('--enabled', 'Only show enabled models')
|
||||
.option(
|
||||
'--type <type>',
|
||||
'Filter by model type (chat|embedding|tts|stt|image|video|text2music|realtime)',
|
||||
)
|
||||
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
|
||||
.action(
|
||||
async (
|
||||
providerId: string,
|
||||
options: { enabled?: boolean; json?: string | boolean; limit?: string },
|
||||
options: { enabled?: boolean; json?: string | boolean; limit?: string; type?: string },
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
const input: Record<string, any> = { id: providerId };
|
||||
if (options.limit) input.limit = Number.parseInt(options.limit, 10);
|
||||
if (options.enabled) input.enabled = true;
|
||||
if (options.type) input.type = options.type;
|
||||
|
||||
const result = await client.aiModel.getAiProviderModelList.query(input as any);
|
||||
const items = Array.isArray(result) ? result : ((result as any).items ?? []);
|
||||
let items = Array.isArray(result) ? result : ((result as any).items ?? []);
|
||||
|
||||
if (options.type) {
|
||||
items = items.filter((m: any) => m.type === options.type);
|
||||
}
|
||||
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
@@ -83,6 +92,65 @@ export function registerModelCommand(program: Command) {
|
||||
if (meta.length > 0) console.log(pc.dim(meta.join(' · ')));
|
||||
});
|
||||
|
||||
// ── create ────────────────────────────────────────────
|
||||
|
||||
model
|
||||
.command('create')
|
||||
.description('Create a new model')
|
||||
.requiredOption('--id <id>', 'Model ID')
|
||||
.requiredOption('--provider <providerId>', 'Provider ID')
|
||||
.option('--display-name <name>', 'Display name')
|
||||
.option(
|
||||
'--type <type>',
|
||||
'Model type (chat|embedding|tts|stt|image|video|text2music|realtime)',
|
||||
'chat',
|
||||
)
|
||||
.action(
|
||||
async (options: { displayName?: string; id: string; provider: string; type?: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
const input: Record<string, any> = {
|
||||
id: options.id,
|
||||
providerId: options.provider,
|
||||
type: options.type || 'chat',
|
||||
};
|
||||
if (options.displayName) input.displayName = options.displayName;
|
||||
|
||||
const resultId = await client.aiModel.createAiModel.mutate(input as any);
|
||||
console.log(`${pc.green('✓')} Created model ${pc.bold(resultId || options.id)}`);
|
||||
},
|
||||
);
|
||||
|
||||
// ── edit ─────────────────────────────────────────────
|
||||
|
||||
model
|
||||
.command('edit <id>')
|
||||
.description('Update model info')
|
||||
.requiredOption('--provider <providerId>', 'Provider ID')
|
||||
.option('--display-name <name>', 'Display name')
|
||||
.option('--type <type>', 'Model type (chat|embedding|tts|stt|image|video|text2music|realtime)')
|
||||
.action(
|
||||
async (id: string, options: { displayName?: string; provider: string; type?: string }) => {
|
||||
if (!options.displayName && !options.type) {
|
||||
log.error('No changes specified. Use --display-name or --type.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
|
||||
const value: Record<string, any> = {};
|
||||
if (options.displayName) value.displayName = options.displayName;
|
||||
if (options.type) value.type = options.type;
|
||||
|
||||
await client.aiModel.updateAiModel.mutate({
|
||||
id,
|
||||
providerId: options.provider,
|
||||
value: value as any,
|
||||
});
|
||||
console.log(`${pc.green('✓')} Updated model ${pc.bold(id)}`);
|
||||
},
|
||||
);
|
||||
|
||||
// ── toggle ────────────────────────────────────────────
|
||||
|
||||
model
|
||||
@@ -130,4 +198,120 @@ export function registerModelCommand(program: Command) {
|
||||
await client.aiModel.removeAiModel.mutate({ id, providerId: options.provider });
|
||||
console.log(`${pc.green('✓')} Deleted model ${pc.bold(id)}`);
|
||||
});
|
||||
|
||||
// ── batch-toggle ────────────────────────────────────
|
||||
|
||||
model
|
||||
.command('batch-toggle <ids...>')
|
||||
.description('Enable or disable multiple models at once')
|
||||
.requiredOption('--provider <providerId>', 'Provider ID')
|
||||
.option('--enable', 'Enable the models')
|
||||
.option('--disable', 'Disable the models')
|
||||
.action(
|
||||
async (ids: string[], options: { disable?: boolean; enable?: boolean; provider: string }) => {
|
||||
if (options.enable === undefined && options.disable === undefined) {
|
||||
log.error('Specify --enable or --disable.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
const enabled = options.enable === true;
|
||||
|
||||
await client.aiModel.batchToggleAiModels.mutate({
|
||||
enabled,
|
||||
id: options.provider,
|
||||
models: ids,
|
||||
} as any);
|
||||
console.log(
|
||||
`${pc.green('✓')} ${enabled ? 'Enabled' : 'Disabled'} ${ids.length} model(s) for provider ${pc.bold(options.provider)}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// ── batch-update ──────────────────────────────────────
|
||||
|
||||
model
|
||||
.command('batch-update <providerId>')
|
||||
.description('Batch update models for a provider')
|
||||
.requiredOption('--models <json>', 'JSON array of model objects')
|
||||
.action(async (providerId: string, options: { models: string }) => {
|
||||
let models: any[];
|
||||
try {
|
||||
models = JSON.parse(options.models);
|
||||
} catch {
|
||||
log.error('Invalid models JSON. Provide a JSON array.');
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(models)) {
|
||||
log.error('--models must be a JSON array.');
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
await client.aiModel.batchUpdateAiModels.mutate({ id: providerId, models } as any);
|
||||
console.log(
|
||||
`${pc.green('✓')} Batch updated ${models.length} model(s) for provider ${pc.bold(providerId)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// ── sort ──────────────────────────────────────────────
|
||||
|
||||
model
|
||||
.command('sort <providerId>')
|
||||
.description('Update model sort order')
|
||||
.requiredOption('--sort-map <json>', 'JSON array of {id, sort, type?} objects')
|
||||
.action(async (providerId: string, options: { sortMap: string }) => {
|
||||
let sortMap: any[];
|
||||
try {
|
||||
sortMap = JSON.parse(options.sortMap);
|
||||
} catch {
|
||||
log.error('Invalid sort-map JSON. Provide a JSON array.');
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(sortMap)) {
|
||||
log.error('--sort-map must be a JSON array.');
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
await client.aiModel.updateAiModelOrder.mutate({ providerId, sortMap } as any);
|
||||
console.log(
|
||||
`${pc.green('✓')} Updated sort order for ${sortMap.length} model(s) in provider ${pc.bold(providerId)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// ── clear ───────────────────────────────────────────
|
||||
|
||||
model
|
||||
.command('clear')
|
||||
.description('Clear models for a provider')
|
||||
.requiredOption('--provider <providerId>', 'Provider ID')
|
||||
.option('--remote', 'Only clear remote/fetched models')
|
||||
.option('--yes', 'Skip confirmation prompt')
|
||||
.action(async (options: { provider: string; remote?: boolean; yes?: boolean }) => {
|
||||
const label = options.remote ? 'remote models' : 'all models';
|
||||
if (!options.yes) {
|
||||
const confirmed = await confirm(
|
||||
`Are you sure you want to clear ${label} for provider ${options.provider}?`,
|
||||
);
|
||||
if (!confirmed) {
|
||||
console.log('Cancelled.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
if (options.remote) {
|
||||
await client.aiModel.clearRemoteModels.mutate({ providerId: options.provider } as any);
|
||||
} else {
|
||||
await client.aiModel.clearModelsByProvider.mutate({ providerId: options.provider } as any);
|
||||
}
|
||||
console.log(`${pc.green('✓')} Cleared ${label} for provider ${pc.bold(options.provider)}`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ const { mockTrpcClient } = vi.hoisted(() => ({
|
||||
mockTrpcClient: {
|
||||
plugin: {
|
||||
createOrInstallPlugin: { mutate: vi.fn() },
|
||||
createPlugin: { mutate: vi.fn() },
|
||||
getPlugins: { query: vi.fn() },
|
||||
removePlugin: { mutate: vi.fn() },
|
||||
updatePlugin: { mutate: vi.fn() },
|
||||
@@ -75,6 +76,50 @@ describe('plugin command', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a plugin', async () => {
|
||||
mockTrpcClient.plugin.createPlugin.mutate.mockResolvedValue({ identifier: 'my-plugin' });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'plugin',
|
||||
'create',
|
||||
'-i',
|
||||
'my-plugin',
|
||||
'--manifest',
|
||||
'{"name":"test"}',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.plugin.createPlugin.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
identifier: 'my-plugin',
|
||||
manifest: { name: 'test' },
|
||||
type: 'plugin',
|
||||
}),
|
||||
);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Created plugin'));
|
||||
});
|
||||
|
||||
it('should reject invalid manifest JSON', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'plugin',
|
||||
'create',
|
||||
'-i',
|
||||
'my-plugin',
|
||||
'--manifest',
|
||||
'not-json',
|
||||
]);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith('Invalid manifest JSON.');
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('install', () => {
|
||||
it('should install a plugin', async () => {
|
||||
mockTrpcClient.plugin.createOrInstallPlugin.mutate.mockResolvedValue({});
|
||||
|
||||
@@ -40,6 +40,56 @@ export function registerPluginCommand(program: Command) {
|
||||
printTable(rows, ['ID', 'IDENTIFIER', 'TYPE', 'TITLE']);
|
||||
});
|
||||
|
||||
// ── create ──────────────────────────────────────────
|
||||
|
||||
plugin
|
||||
.command('create')
|
||||
.description('Create a new plugin (without settings)')
|
||||
.requiredOption('-i, --identifier <id>', 'Plugin identifier')
|
||||
.requiredOption('--manifest <json>', 'Plugin manifest JSON')
|
||||
.option('--type <type>', 'Plugin type: plugin or customPlugin', 'plugin')
|
||||
.option('--custom-params <json>', 'Custom parameters JSON')
|
||||
.action(
|
||||
async (options: {
|
||||
customParams?: string;
|
||||
identifier: string;
|
||||
manifest: string;
|
||||
type: string;
|
||||
}) => {
|
||||
let manifest: any;
|
||||
let customParams: any = {};
|
||||
try {
|
||||
manifest = JSON.parse(options.manifest);
|
||||
} catch {
|
||||
log.error('Invalid manifest JSON.');
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
if (options.customParams) {
|
||||
try {
|
||||
customParams = JSON.parse(options.customParams);
|
||||
} catch {
|
||||
log.error('Invalid custom-params JSON.');
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.plugin.createPlugin.mutate({
|
||||
customParams,
|
||||
identifier: options.identifier,
|
||||
manifest,
|
||||
type: options.type as 'plugin' | 'customPlugin',
|
||||
});
|
||||
|
||||
const r = result as any;
|
||||
console.log(
|
||||
`${pc.green('✓')} Created plugin ${pc.bold(r.identifier || options.identifier)}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// ── install ───────────────────────────────────────────
|
||||
|
||||
plugin
|
||||
|
||||
@@ -7,10 +7,14 @@ import { registerProviderCommand } from './provider';
|
||||
const { mockTrpcClient } = vi.hoisted(() => ({
|
||||
mockTrpcClient: {
|
||||
aiProvider: {
|
||||
checkProviderConnectivity: { mutate: vi.fn() },
|
||||
createAiProvider: { mutate: vi.fn() },
|
||||
getAiProviderById: { query: vi.fn() },
|
||||
getAiProviderList: { query: vi.fn() },
|
||||
removeAiProvider: { mutate: vi.fn() },
|
||||
toggleProviderEnabled: { mutate: vi.fn() },
|
||||
updateAiProvider: { mutate: vi.fn() },
|
||||
updateAiProviderConfig: { mutate: vi.fn() },
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -97,6 +101,195 @@ describe('provider command', () => {
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('not found'));
|
||||
});
|
||||
|
||||
it('should exit when empty object returned', async () => {
|
||||
mockTrpcClient.aiProvider.getAiProviderById.query.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'provider', 'view', 'nonexistent']);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('not found'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a provider', async () => {
|
||||
mockTrpcClient.aiProvider.createAiProvider.mutate.mockResolvedValue('my-provider');
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'provider',
|
||||
'create',
|
||||
'--id',
|
||||
'my-provider',
|
||||
'-n',
|
||||
'My Provider',
|
||||
'-d',
|
||||
'Test desc',
|
||||
'--sdk-type',
|
||||
'openai',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.aiProvider.createAiProvider.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'my-provider',
|
||||
name: 'My Provider',
|
||||
description: 'Test desc',
|
||||
sdkType: 'openai',
|
||||
source: 'custom',
|
||||
}),
|
||||
);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Created provider'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('edit', () => {
|
||||
it('should update provider name', async () => {
|
||||
mockTrpcClient.aiProvider.updateAiProvider.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'provider', 'edit', 'openai', '-n', 'New Name']);
|
||||
|
||||
expect(mockTrpcClient.aiProvider.updateAiProvider.mutate).toHaveBeenCalledWith({
|
||||
id: 'openai',
|
||||
value: expect.objectContaining({ name: 'New Name' }),
|
||||
});
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Updated provider'));
|
||||
});
|
||||
|
||||
it('should error when no changes specified', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'provider', 'edit', 'openai']);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('No changes'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('config', () => {
|
||||
it('should set api key and base url', async () => {
|
||||
mockTrpcClient.aiProvider.updateAiProviderConfig.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'provider',
|
||||
'config',
|
||||
'openai',
|
||||
'--api-key',
|
||||
'sk-test',
|
||||
'--base-url',
|
||||
'https://api.test.com/v1',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.aiProvider.updateAiProviderConfig.mutate).toHaveBeenCalledWith({
|
||||
id: 'openai',
|
||||
value: expect.objectContaining({
|
||||
keyVaults: { apiKey: 'sk-test', baseURL: 'https://api.test.com/v1' },
|
||||
}),
|
||||
});
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Updated config'));
|
||||
});
|
||||
|
||||
it('should enable response api', async () => {
|
||||
mockTrpcClient.aiProvider.updateAiProviderConfig.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'provider',
|
||||
'config',
|
||||
'openai',
|
||||
'--enable-response-api',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.aiProvider.updateAiProviderConfig.mutate).toHaveBeenCalledWith({
|
||||
id: 'openai',
|
||||
value: expect.objectContaining({
|
||||
config: { enableResponseApi: true },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should show current config', async () => {
|
||||
mockTrpcClient.aiProvider.getAiProviderById.query.mockResolvedValue({
|
||||
checkModel: 'gpt-4o',
|
||||
fetchOnClient: true,
|
||||
id: 'openai',
|
||||
keyVaults: { apiKey: 'sk-test12345678', baseURL: 'https://api.test.com/v1' },
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'provider', 'config', 'openai', '--show']);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Config for openai'));
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('gpt-4o'));
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('https://api.test.com/v1'));
|
||||
});
|
||||
|
||||
it('should error when no config specified', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'provider', 'config', 'openai']);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('No config specified'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('test', () => {
|
||||
it('should show success when provider is reachable', async () => {
|
||||
mockTrpcClient.aiProvider.checkProviderConnectivity.mutate.mockResolvedValue({
|
||||
model: 'gpt-4o',
|
||||
ok: true,
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'provider', 'test', 'openai', '--model', 'gpt-4o']);
|
||||
|
||||
expect(mockTrpcClient.aiProvider.checkProviderConnectivity.mutate).toHaveBeenCalledWith({
|
||||
id: 'openai',
|
||||
model: 'gpt-4o',
|
||||
});
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('reachable'));
|
||||
});
|
||||
|
||||
it('should show failure and exit 1', async () => {
|
||||
mockTrpcClient.aiProvider.checkProviderConnectivity.mutate.mockResolvedValue({
|
||||
error: 'InvalidProviderAPIKey',
|
||||
model: 'gpt-4o',
|
||||
ok: false,
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'provider', 'test', 'openai', '--model', 'gpt-4o']);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('check failed'));
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('InvalidProviderAPIKey'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should output JSON', async () => {
|
||||
const result = { model: 'gpt-4o', ok: true };
|
||||
mockTrpcClient.aiProvider.checkProviderConnectivity.mutate.mockResolvedValue(result);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'provider',
|
||||
'test',
|
||||
'openai',
|
||||
'--model',
|
||||
'gpt-4o',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(result, null, 2));
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggle', () => {
|
||||
@@ -111,6 +304,14 @@ describe('provider command', () => {
|
||||
id: 'openai',
|
||||
});
|
||||
});
|
||||
|
||||
it('should error when no flag specified', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'provider', 'toggle', 'openai']);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('--enable or --disable'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
|
||||
@@ -50,7 +50,7 @@ export function registerProviderCommand(program: Command) {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.aiProvider.getAiProviderById.query({ id });
|
||||
|
||||
if (!result) {
|
||||
if (!result || !(result as any).id) {
|
||||
log.error(`Provider not found: ${id}`);
|
||||
process.exit(1);
|
||||
return;
|
||||
@@ -63,13 +63,216 @@ export function registerProviderCommand(program: Command) {
|
||||
}
|
||||
|
||||
const r = result as any;
|
||||
console.log(pc.bold(r.name || r.id || 'Unknown'));
|
||||
console.log(pc.bold(r.name || r.id));
|
||||
const meta: string[] = [];
|
||||
if (r.enabled !== undefined) meta.push(r.enabled ? 'Enabled' : 'Disabled');
|
||||
if (r.source) meta.push(`Source: ${r.source}`);
|
||||
if (meta.length > 0) console.log(pc.dim(meta.join(' · ')));
|
||||
});
|
||||
|
||||
// ── create ────────────────────────────────────────────
|
||||
|
||||
provider
|
||||
.command('create')
|
||||
.description('Create a new AI provider')
|
||||
.requiredOption('--id <id>', 'Provider ID')
|
||||
.requiredOption('-n, --name <name>', 'Provider name')
|
||||
.option('-s, --source <source>', 'Source type (builtin|custom)', 'custom')
|
||||
.option('-d, --description <desc>', 'Provider description')
|
||||
.option('--logo <logo>', 'Provider logo URL')
|
||||
.option('--sdk-type <sdkType>', 'SDK type (openai|anthropic|azure|bedrock|...)')
|
||||
.action(
|
||||
async (options: {
|
||||
description?: string;
|
||||
id: string;
|
||||
logo?: string;
|
||||
name: string;
|
||||
sdkType?: string;
|
||||
source?: string;
|
||||
}) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
const input: Record<string, any> = {
|
||||
id: options.id,
|
||||
name: options.name,
|
||||
source: options.source || 'custom',
|
||||
};
|
||||
if (options.description) input.description = options.description;
|
||||
if (options.logo) input.logo = options.logo;
|
||||
if (options.sdkType) input.sdkType = options.sdkType;
|
||||
|
||||
const resultId = await client.aiProvider.createAiProvider.mutate(input as any);
|
||||
console.log(`${pc.green('✓')} Created provider ${pc.bold(resultId || options.id)}`);
|
||||
},
|
||||
);
|
||||
|
||||
// ── edit ─────────────────────────────────────────────
|
||||
|
||||
provider
|
||||
.command('edit <id>')
|
||||
.description('Update provider info')
|
||||
.option('-n, --name <name>', 'Provider name')
|
||||
.option('-d, --description <desc>', 'Provider description')
|
||||
.option('--logo <logo>', 'Provider logo URL')
|
||||
.option('--sdk-type <sdkType>', 'SDK type')
|
||||
.action(
|
||||
async (
|
||||
id: string,
|
||||
options: { description?: string; logo?: string; name?: string; sdkType?: string },
|
||||
) => {
|
||||
if (!options.name && !options.description && !options.logo && !options.sdkType) {
|
||||
log.error('No changes specified. Use --name, --description, --logo, or --sdk-type.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
|
||||
const value: Record<string, any> = {};
|
||||
if (options.name) value.name = options.name;
|
||||
if (options.description !== undefined) value.description = options.description;
|
||||
if (options.logo !== undefined) value.logo = options.logo;
|
||||
if (options.sdkType) value.sdkType = options.sdkType;
|
||||
|
||||
await client.aiProvider.updateAiProvider.mutate({ id, value: value as any });
|
||||
console.log(`${pc.green('✓')} Updated provider ${pc.bold(id)}`);
|
||||
},
|
||||
);
|
||||
|
||||
// ── config ──────────────────────────────────────────
|
||||
|
||||
provider
|
||||
.command('config <id>')
|
||||
.description('Configure provider settings (API key, base URL, etc.)')
|
||||
.option('--api-key <key>', 'Set API key')
|
||||
.option('--base-url <url>', 'Set base URL')
|
||||
.option('--check-model <model>', 'Set connectivity check model')
|
||||
.option('--enable-response-api', 'Enable Response API mode (OpenAI)')
|
||||
.option('--disable-response-api', 'Disable Response API mode')
|
||||
.option('--fetch-on-client', 'Enable fetching models on client side')
|
||||
.option('--no-fetch-on-client', 'Disable fetching models on client side')
|
||||
.option('--show', 'Show current config')
|
||||
.option('--json [fields]', 'Output JSON (with --show)')
|
||||
.action(
|
||||
async (
|
||||
id: string,
|
||||
options: {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
checkModel?: string;
|
||||
disableResponseApi?: boolean;
|
||||
enableResponseApi?: boolean;
|
||||
fetchOnClient?: boolean;
|
||||
json?: string | boolean;
|
||||
show?: boolean;
|
||||
},
|
||||
) => {
|
||||
// lobehub is a platform-managed provider, users cannot configure its API key or base URL
|
||||
if (id === 'lobehub' && (options.apiKey !== undefined || options.baseUrl !== undefined)) {
|
||||
log.error(
|
||||
`Provider "lobehub" is managed by the LobeHub platform. You cannot set --api-key or --base-url for it.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
|
||||
// Show current config
|
||||
if (options.show) {
|
||||
const detail = await client.aiProvider.getAiProviderById.query({ id });
|
||||
if (!detail) {
|
||||
log.error(`Provider not found: ${id}`);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const config: Record<string, any> = {
|
||||
checkModel: (detail as any).checkModel || '',
|
||||
fetchOnClient: (detail as any).fetchOnClient ?? false,
|
||||
keyVaults: (detail as any).keyVaults || {},
|
||||
};
|
||||
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(config, fields);
|
||||
} else {
|
||||
console.log(pc.bold(`Config for ${id}`));
|
||||
if (config.checkModel) console.log(` Check Model: ${config.checkModel}`);
|
||||
console.log(` Fetch on Client: ${config.fetchOnClient ? pc.green('✓') : pc.dim('✗')}`);
|
||||
const vaults = config.keyVaults;
|
||||
if (vaults.apiKey)
|
||||
console.log(` API Key: ${pc.dim(vaults.apiKey.slice(0, 8) + '...')}`);
|
||||
if (vaults.baseURL) console.log(` Base URL: ${vaults.baseURL}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Build config update
|
||||
const hasKeyVaults = options.apiKey !== undefined || options.baseUrl !== undefined;
|
||||
const hasConfig = options.enableResponseApi || options.disableResponseApi;
|
||||
const hasOther = options.checkModel !== undefined || options.fetchOnClient !== undefined;
|
||||
|
||||
if (!hasKeyVaults && !hasConfig && !hasOther) {
|
||||
log.error(
|
||||
'No config specified. Use --api-key, --base-url, --check-model, --enable-response-api, --fetch-on-client, or --show.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const input: Record<string, any> = {};
|
||||
|
||||
if (hasKeyVaults) {
|
||||
const keyVaults: Record<string, string> = {};
|
||||
if (options.apiKey !== undefined) keyVaults.apiKey = options.apiKey;
|
||||
if (options.baseUrl !== undefined) keyVaults.baseURL = options.baseUrl;
|
||||
input.keyVaults = keyVaults;
|
||||
}
|
||||
|
||||
if (hasConfig) {
|
||||
input.config = { enableResponseApi: !!options.enableResponseApi };
|
||||
}
|
||||
|
||||
if (options.checkModel !== undefined) input.checkModel = options.checkModel;
|
||||
if (options.fetchOnClient !== undefined) input.fetchOnClient = options.fetchOnClient;
|
||||
|
||||
await client.aiProvider.updateAiProviderConfig.mutate({ id, value: input as any });
|
||||
console.log(`${pc.green('✓')} Updated config for provider ${pc.bold(id)}`);
|
||||
},
|
||||
);
|
||||
|
||||
// ── test ─────────────────────────────────────────────
|
||||
|
||||
provider
|
||||
.command('test <id>')
|
||||
.description('Test provider connectivity')
|
||||
.option('-m, --model <model>', 'Model to test with (defaults to provider checkModel)')
|
||||
.option('--json', 'Output result as JSON')
|
||||
.action(async (id: string, options: { json?: boolean; model?: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
console.log(`${pc.yellow('⋯')} Testing provider ${pc.bold(id)}...`);
|
||||
|
||||
const result = (await client.aiProvider.checkProviderConnectivity.mutate({
|
||||
id,
|
||||
model: options.model,
|
||||
})) as any;
|
||||
|
||||
if (options.json) {
|
||||
outputJson(result);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.ok) {
|
||||
console.log(
|
||||
`${pc.green('✓')} Provider ${pc.bold(id)} is reachable (model: ${result.model})`,
|
||||
);
|
||||
} else {
|
||||
console.log(`${pc.red('✗')} Provider ${pc.bold(id)} check failed`);
|
||||
if (result.model) console.log(` Model: ${result.model}`);
|
||||
if (result.error) console.log(` Error: ${pc.dim(result.error)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// ── toggle ────────────────────────────────────────────
|
||||
|
||||
provider
|
||||
|
||||
+270
-54
@@ -1,7 +1,7 @@
|
||||
import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../api/client';
|
||||
import { getToolsTrpcClient, getTrpcClient } from '../api/client';
|
||||
import { outputJson, printTable, truncate } from '../utils/format';
|
||||
|
||||
const SEARCH_TYPES = [
|
||||
@@ -36,67 +36,283 @@ function renderResultGroup(type: string, items: any[]) {
|
||||
}
|
||||
|
||||
export function registerSearchCommand(program: Command) {
|
||||
program
|
||||
.command('search <query>')
|
||||
.description('Search across topics, agents, files, knowledge bases, and more')
|
||||
const search = program
|
||||
.command('search')
|
||||
.description('Search across local resources or the web')
|
||||
.option('-q, --query <query>', 'Search query')
|
||||
.option('-w, --web', 'Search the web instead of local resources')
|
||||
.option('-t, --type <type>', `Filter by type: ${SEARCH_TYPES.join(', ')}`)
|
||||
.option('-L, --limit <n>', 'Results per type', '10')
|
||||
.option('-e, --engines <engines>', 'Web search engines (comma-separated, requires --web)')
|
||||
.option(
|
||||
'-c, --categories <categories>',
|
||||
'Web search categories (comma-separated, requires --web)',
|
||||
)
|
||||
.option(
|
||||
'-T, --time-range <range>',
|
||||
'Time range filter (e.g. day, week, month, year, requires --web)',
|
||||
)
|
||||
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
|
||||
.action(
|
||||
async (
|
||||
query: string,
|
||||
options: { json?: string | boolean; limit?: string; type?: string },
|
||||
) => {
|
||||
if (options.type && !SEARCH_TYPES.includes(options.type as SearchType)) {
|
||||
console.error(
|
||||
`Invalid type: ${options.type}. Must be one of: ${SEARCH_TYPES.join(', ')}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
|
||||
const input: { limitPerType?: number; query: string; type?: SearchType } = { query };
|
||||
if (options.type) input.type = options.type as SearchType;
|
||||
if (options.limit) input.limitPerType = Number.parseInt(options.limit, 10);
|
||||
|
||||
const result = await client.search.query.query(input);
|
||||
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(result, fields);
|
||||
async (options: {
|
||||
categories?: string;
|
||||
engines?: string;
|
||||
json?: string | boolean;
|
||||
limit?: string;
|
||||
query?: string;
|
||||
timeRange?: string;
|
||||
type?: string;
|
||||
web?: boolean;
|
||||
}) => {
|
||||
if (!options.query) {
|
||||
search.help();
|
||||
return;
|
||||
}
|
||||
|
||||
// result is expected to be an object grouped by type or an array
|
||||
if (Array.isArray(result)) {
|
||||
if (result.length === 0) {
|
||||
console.log('No results found.');
|
||||
return;
|
||||
}
|
||||
// Group by type if available
|
||||
const groups: Record<string, any[]> = {};
|
||||
for (const item of result) {
|
||||
const t = item.type || 'other';
|
||||
if (!groups[t]) groups[t] = [];
|
||||
groups[t].push(item);
|
||||
}
|
||||
for (const [type, items] of Object.entries(groups)) {
|
||||
renderResultGroup(type, items);
|
||||
}
|
||||
} else if (result && typeof result === 'object') {
|
||||
const groups = result as Record<string, any[]>;
|
||||
let hasResults = false;
|
||||
for (const [type, items] of Object.entries(groups)) {
|
||||
if (Array.isArray(items) && items.length > 0) {
|
||||
hasResults = true;
|
||||
renderResultGroup(type, items);
|
||||
}
|
||||
}
|
||||
if (!hasResults) {
|
||||
console.log('No results found.');
|
||||
}
|
||||
if (options.web) {
|
||||
await webSearch(options.query, options);
|
||||
} else {
|
||||
await localSearch(options.query, options);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ── search view ──────────────────────────────────────
|
||||
search
|
||||
.command('view <target>')
|
||||
.description('View details of a search result (URL for web results, or type:id for local)')
|
||||
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
|
||||
.option(
|
||||
'-i, --impl <impls>',
|
||||
'Crawler implementations for web URLs (comma-separated: browserless, exa, firecrawl, jina, naive, search1api, tavily)',
|
||||
)
|
||||
.action(
|
||||
async (
|
||||
target: string,
|
||||
options: {
|
||||
impl?: string;
|
||||
json?: string | boolean;
|
||||
},
|
||||
) => {
|
||||
if (target.startsWith('http://') || target.startsWith('https://')) {
|
||||
await crawlView(target, options);
|
||||
return;
|
||||
}
|
||||
|
||||
await localView(target, options);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ── local search ──────────────────────────────────────
|
||||
|
||||
async function localSearch(
|
||||
query: string,
|
||||
options: { json?: string | boolean; limit?: string; type?: string },
|
||||
) {
|
||||
if (options.type && !SEARCH_TYPES.includes(options.type as SearchType)) {
|
||||
console.error(`Invalid type: ${options.type}. Must be one of: ${SEARCH_TYPES.join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
|
||||
const input: { limitPerType?: number; query: string; type?: SearchType } = { query };
|
||||
if (options.type) input.type = options.type as SearchType;
|
||||
if (options.limit) input.limitPerType = Number.parseInt(options.limit, 10);
|
||||
|
||||
const result = await client.search.query.query(input);
|
||||
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(result, fields);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(result)) {
|
||||
if (result.length === 0) {
|
||||
console.log('No results found.');
|
||||
return;
|
||||
}
|
||||
const groups: Record<string, any[]> = {};
|
||||
for (const item of result) {
|
||||
const t = item.type || 'other';
|
||||
if (!groups[t]) groups[t] = [];
|
||||
groups[t].push(item);
|
||||
}
|
||||
for (const [type, items] of Object.entries(groups)) {
|
||||
renderResultGroup(type, items);
|
||||
}
|
||||
} else if (result && typeof result === 'object') {
|
||||
const groups = result as Record<string, any[]>;
|
||||
let hasResults = false;
|
||||
for (const [type, items] of Object.entries(groups)) {
|
||||
if (Array.isArray(items) && items.length > 0) {
|
||||
hasResults = true;
|
||||
renderResultGroup(type, items);
|
||||
}
|
||||
}
|
||||
if (!hasResults) {
|
||||
console.log('No results found.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── web search ────────────────────────────────────────
|
||||
|
||||
async function webSearch(
|
||||
query: string,
|
||||
options: {
|
||||
categories?: string;
|
||||
engines?: string;
|
||||
json?: string | boolean;
|
||||
timeRange?: string;
|
||||
},
|
||||
) {
|
||||
const toolsClient = await getToolsTrpcClient();
|
||||
|
||||
const input: {
|
||||
query: string;
|
||||
searchCategories?: string[];
|
||||
searchEngines?: string[];
|
||||
searchTimeRange?: string;
|
||||
} = { query };
|
||||
|
||||
if (options.engines) input.searchEngines = options.engines.split(',').map((s) => s.trim());
|
||||
if (options.categories)
|
||||
input.searchCategories = options.categories.split(',').map((s) => s.trim());
|
||||
if (options.timeRange) input.searchTimeRange = options.timeRange;
|
||||
|
||||
const result = await toolsClient.search.webSearch.query(input);
|
||||
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(result, fields);
|
||||
return;
|
||||
}
|
||||
|
||||
const res = result as any;
|
||||
|
||||
console.log(
|
||||
pc.dim(
|
||||
`Found ${res.resultNumbers ?? res.results?.length ?? 0} results in ${res.costTime ?? '?'}ms`,
|
||||
),
|
||||
);
|
||||
|
||||
if (!res.results || res.results.length === 0) {
|
||||
console.log('No results found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = res.results.map((item: any) => [
|
||||
truncate(item.title || '', 50),
|
||||
truncate(item.url || '', 60),
|
||||
item.score != null ? String(item.score) : '',
|
||||
truncate(item.content || '', 60),
|
||||
]);
|
||||
|
||||
printTable(rows, ['TITLE', 'URL', 'SCORE', 'CONTENT']);
|
||||
}
|
||||
|
||||
// ── crawl view (for web URLs) ─────────────────────────
|
||||
|
||||
async function crawlView(url: string, options: { impl?: string; json?: string | boolean }) {
|
||||
const toolsClient = await getToolsTrpcClient();
|
||||
|
||||
const input: {
|
||||
impls?: ('browserless' | 'exa' | 'firecrawl' | 'jina' | 'naive' | 'search1api' | 'tavily')[];
|
||||
urls: string[];
|
||||
} = { urls: [url] };
|
||||
|
||||
if (options.impl) {
|
||||
input.impls = options.impl.split(',').map((s) => s.trim()) as typeof input.impls;
|
||||
}
|
||||
|
||||
const result = await toolsClient.search.crawlPages.mutate(input);
|
||||
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(result, fields);
|
||||
return;
|
||||
}
|
||||
|
||||
const pages = Array.isArray(result) ? result : [result];
|
||||
|
||||
for (const page of pages) {
|
||||
const p = page as any;
|
||||
console.log();
|
||||
console.log(pc.bold(pc.cyan(p.title || p.url || 'Untitled')));
|
||||
if (p.url) console.log(pc.dim(p.url));
|
||||
if (p.content) {
|
||||
console.log();
|
||||
console.log(p.content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── local view (by type:id) ───────────────────────────
|
||||
|
||||
async function localView(target: string, options: { json?: string | boolean }) {
|
||||
const sep = target.indexOf(':');
|
||||
if (sep === -1) {
|
||||
console.error(
|
||||
'Invalid target. Use type:id (e.g. agent:abc123) for local resources, or a URL for web results.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const type = target.slice(0, sep);
|
||||
const id = target.slice(sep + 1);
|
||||
|
||||
if (!id) {
|
||||
console.error('Missing id. Format: type:id');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
|
||||
let result: any;
|
||||
|
||||
switch (type) {
|
||||
case 'agent': {
|
||||
result = await client.agent.getAgentConfigById.query({ agentId: id });
|
||||
break;
|
||||
}
|
||||
case 'file': {
|
||||
result = await client.file.getFileItemById.query({ id });
|
||||
break;
|
||||
}
|
||||
case 'knowledgeBase': {
|
||||
result = await client.knowledgeBase.getKnowledgeBaseById.query({ id });
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
console.error(`View not supported for type "${type}". Supported: agent, file, knowledgeBase`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
console.error(`${type} not found: ${id}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(result, fields);
|
||||
return;
|
||||
}
|
||||
|
||||
const r = result as any;
|
||||
console.log();
|
||||
console.log(pc.bold(r.title || r.name || r.identifier || id));
|
||||
if (r.description) console.log(pc.dim(r.description));
|
||||
if (r.type) console.log(`Type: ${r.type}`);
|
||||
if (r.createdAt) console.log(`Created: ${pc.dim(String(r.createdAt))}`);
|
||||
if (r.updatedAt) console.log(`Updated: ${pc.dim(String(r.updatedAt))}`);
|
||||
if (r.systemRole) {
|
||||
console.log();
|
||||
console.log(pc.cyan('System Role:'));
|
||||
console.log(r.systemRole);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { registerSessionGroupCommand } from './session-group';
|
||||
|
||||
const { mockTrpcClient } = vi.hoisted(() => ({
|
||||
mockTrpcClient: {
|
||||
sessionGroup: {
|
||||
createSessionGroup: { mutate: vi.fn() },
|
||||
getSessionGroup: { query: vi.fn() },
|
||||
removeSessionGroup: { mutate: vi.fn() },
|
||||
updateSessionGroup: { mutate: vi.fn() },
|
||||
updateSessionGroupOrder: { mutate: vi.fn() },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const { getTrpcClient: mockGetTrpcClient } = vi.hoisted(() => ({
|
||||
getTrpcClient: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../api/client', () => ({ getTrpcClient: mockGetTrpcClient }));
|
||||
vi.mock('../utils/logger', () => ({
|
||||
log: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() },
|
||||
setVerbose: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('session-group command', () => {
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
|
||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
mockGetTrpcClient.mockResolvedValue(mockTrpcClient);
|
||||
for (const method of Object.values(mockTrpcClient.sessionGroup)) {
|
||||
for (const fn of Object.values(method)) {
|
||||
(fn as ReturnType<typeof vi.fn>).mockReset();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
exitSpy.mockRestore();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
function createProgram() {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerSessionGroupCommand(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
describe('list', () => {
|
||||
it('should list session groups', async () => {
|
||||
mockTrpcClient.sessionGroup.getSessionGroup.query.mockResolvedValue([
|
||||
{ id: 'sg1', name: 'Group 1', sort: 0 },
|
||||
]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'session-group', 'list']);
|
||||
|
||||
expect(mockTrpcClient.sessionGroup.getSessionGroup.query).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should show empty message when no groups', async () => {
|
||||
mockTrpcClient.sessionGroup.getSessionGroup.query.mockResolvedValue([]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'session-group', 'list']);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith('No session groups found.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a session group', async () => {
|
||||
mockTrpcClient.sessionGroup.createSessionGroup.mutate.mockResolvedValue('sg1');
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'session-group', 'create', '-n', 'My Group']);
|
||||
|
||||
expect(mockTrpcClient.sessionGroup.createSessionGroup.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'My Group' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edit', () => {
|
||||
it('should update a session group', async () => {
|
||||
mockTrpcClient.sessionGroup.updateSessionGroup.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'session-group', 'edit', 'sg1', '-n', 'New Name']);
|
||||
|
||||
expect(mockTrpcClient.sessionGroup.updateSessionGroup.mutate).toHaveBeenCalledWith({
|
||||
id: 'sg1',
|
||||
value: expect.objectContaining({ name: 'New Name' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('should error when no changes specified', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'session-group', 'edit', 'sg1']);
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete a session group', async () => {
|
||||
mockTrpcClient.sessionGroup.removeSessionGroup.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'session-group', 'delete', 'sg1', '--yes']);
|
||||
|
||||
expect(mockTrpcClient.sessionGroup.removeSessionGroup.mutate).toHaveBeenCalledWith({
|
||||
id: 'sg1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('sort', () => {
|
||||
it('should update sort order', async () => {
|
||||
mockTrpcClient.sessionGroup.updateSessionGroupOrder.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'session-group', 'sort', '--map', 'sg1:0,sg2:1']);
|
||||
|
||||
expect(mockTrpcClient.sessionGroup.updateSessionGroupOrder.mutate).toHaveBeenCalledWith({
|
||||
sortMap: [
|
||||
{ id: 'sg1', sort: 0 },
|
||||
{ id: 'sg2', sort: 1 },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../api/client';
|
||||
import { confirm, outputJson, printTable } from '../utils/format';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
export function registerSessionGroupCommand(program: Command) {
|
||||
const sessionGroup = program.command('session-group').description('Manage agent session groups');
|
||||
|
||||
// ── list ──────────────────────────────────────────────
|
||||
|
||||
sessionGroup
|
||||
.command('list')
|
||||
.description('List all session groups')
|
||||
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
|
||||
.action(async (options: { json?: string | boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const groups = await client.sessionGroup.getSessionGroup.query();
|
||||
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(groups, fields);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!groups || (groups as any[]).length === 0) {
|
||||
console.log('No session groups found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = (groups as any[]).map((g: any) => [
|
||||
g.id || '',
|
||||
g.name || '',
|
||||
String(g.sort ?? ''),
|
||||
]);
|
||||
|
||||
printTable(rows, ['ID', 'NAME', 'SORT']);
|
||||
});
|
||||
|
||||
// ── create ────────────────────────────────────────────
|
||||
|
||||
sessionGroup
|
||||
.command('create')
|
||||
.description('Create a session group')
|
||||
.requiredOption('-n, --name <name>', 'Group name')
|
||||
.option('-s, --sort <n>', 'Sort order')
|
||||
.action(async (options: { name: string; sort?: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
const input: Record<string, any> = { name: options.name };
|
||||
if (options.sort) input.sort = Number.parseInt(options.sort, 10);
|
||||
|
||||
const id = await client.sessionGroup.createSessionGroup.mutate(input as any);
|
||||
console.log(`${pc.green('✓')} Created session group ${pc.bold(String(id || ''))}`);
|
||||
});
|
||||
|
||||
// ── edit ───────────────────────────────────────────────
|
||||
|
||||
sessionGroup
|
||||
.command('edit <id>')
|
||||
.description('Update a session group')
|
||||
.option('-n, --name <name>', 'Group name')
|
||||
.option('-s, --sort <n>', 'Sort order')
|
||||
.action(async (id: string, options: { name?: string; sort?: string }) => {
|
||||
const value: Record<string, any> = {};
|
||||
if (options.name) value.name = options.name;
|
||||
if (options.sort) value.sort = Number.parseInt(options.sort, 10);
|
||||
|
||||
if (Object.keys(value).length === 0) {
|
||||
log.error('No changes specified. Use --name or --sort.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
await client.sessionGroup.updateSessionGroup.mutate({ id, value } as any);
|
||||
console.log(`${pc.green('✓')} Updated session group ${pc.bold(id)}`);
|
||||
});
|
||||
|
||||
// ── delete ────────────────────────────────────────────
|
||||
|
||||
sessionGroup
|
||||
.command('delete <id>')
|
||||
.description('Delete a session group')
|
||||
.option('--yes', 'Skip confirmation prompt')
|
||||
.action(async (id: string, options: { yes?: boolean }) => {
|
||||
if (!options.yes) {
|
||||
const confirmed = await confirm('Are you sure you want to delete this session group?');
|
||||
if (!confirmed) {
|
||||
console.log('Cancelled.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
await client.sessionGroup.removeSessionGroup.mutate({ id });
|
||||
console.log(`${pc.green('✓')} Deleted session group ${pc.bold(id)}`);
|
||||
});
|
||||
|
||||
// ── sort ──────────────────────────────────────────────
|
||||
|
||||
sessionGroup
|
||||
.command('sort')
|
||||
.description('Update session group sort order')
|
||||
.requiredOption('--map <entries>', 'Comma-separated id:sort pairs (e.g. "id1:0,id2:1,id3:2")')
|
||||
.action(async (options: { map: string }) => {
|
||||
const sortMap = options.map.split(',').map((entry) => {
|
||||
const [id, sort] = entry.trim().split(':');
|
||||
if (!id || sort === undefined) {
|
||||
log.error(`Invalid sort entry: "${entry}". Use format "id:sort".`);
|
||||
process.exit(1);
|
||||
}
|
||||
return { id, sort: Number.parseInt(sort, 10) };
|
||||
});
|
||||
|
||||
const client = await getTrpcClient();
|
||||
await client.sessionGroup.updateSessionGroupOrder.mutate({ sortMap });
|
||||
console.log(`${pc.green('✓')} Updated sort order for ${sortMap.length} group(s)`);
|
||||
});
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { Command } from 'commander';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { log } from '../utils/logger';
|
||||
import { registerSkillCommand } from './skill';
|
||||
import { detectSourceType, registerSkillCommand } from './skill';
|
||||
|
||||
const { mockTrpcClient } = vi.hoisted(() => ({
|
||||
mockTrpcClient: {
|
||||
@@ -232,8 +232,8 @@ describe('skill command', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('import-github', () => {
|
||||
it('should import from GitHub', async () => {
|
||||
describe('install', () => {
|
||||
it('should install from GitHub URL', async () => {
|
||||
mockTrpcClient.agentSkills.importFromGitHub.mutate.mockResolvedValue({
|
||||
id: 'imported',
|
||||
name: 'GH Skill',
|
||||
@@ -244,37 +244,111 @@ describe('skill command', () => {
|
||||
'node',
|
||||
'test',
|
||||
'skill',
|
||||
'import-github',
|
||||
'--url',
|
||||
'install',
|
||||
'https://github.com/user/repo',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentSkills.importFromGitHub.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ gitUrl: 'https://github.com/user/repo' }),
|
||||
);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Imported'));
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Installed'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('import-market', () => {
|
||||
it('should install from marketplace', async () => {
|
||||
mockTrpcClient.agentSkills.importFromMarket.mutate.mockResolvedValue({ id: 'mk1' });
|
||||
it('should install from GitHub shorthand (owner/repo)', async () => {
|
||||
mockTrpcClient.agentSkills.importFromGitHub.mutate.mockResolvedValue({
|
||||
id: 'imported',
|
||||
name: 'GH Skill',
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'skill', 'install', 'lobehub/skill-repo']);
|
||||
|
||||
expect(mockTrpcClient.agentSkills.importFromGitHub.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ gitUrl: 'https://github.com/lobehub/skill-repo' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should install from GitHub with --branch', async () => {
|
||||
mockTrpcClient.agentSkills.importFromGitHub.mutate.mockResolvedValue({ id: 'imported' });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'skill',
|
||||
'import-market',
|
||||
'--identifier',
|
||||
'some-skill',
|
||||
'install',
|
||||
'lobehub/skill-repo',
|
||||
'--branch',
|
||||
'dev',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentSkills.importFromGitHub.mutate).toHaveBeenCalledWith({
|
||||
branch: 'dev',
|
||||
gitUrl: 'https://github.com/lobehub/skill-repo',
|
||||
});
|
||||
});
|
||||
|
||||
it('should install from ZIP URL', async () => {
|
||||
mockTrpcClient.agentSkills.importFromUrl.mutate.mockResolvedValue({ id: 'zip1' });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'skill',
|
||||
'install',
|
||||
'https://example.com/skill.zip',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentSkills.importFromUrl.mutate).toHaveBeenCalledWith({
|
||||
url: 'https://example.com/skill.zip',
|
||||
});
|
||||
});
|
||||
|
||||
it('should install from marketplace by identifier', async () => {
|
||||
mockTrpcClient.agentSkills.importFromMarket.mutate.mockResolvedValue({ id: 'mk1' });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'skill', 'install', 'some-skill']);
|
||||
|
||||
expect(mockTrpcClient.agentSkills.importFromMarket.mutate).toHaveBeenCalledWith({
|
||||
identifier: 'some-skill',
|
||||
});
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('some-skill'));
|
||||
});
|
||||
|
||||
it('should work with alias "i"', async () => {
|
||||
mockTrpcClient.agentSkills.importFromMarket.mutate.mockResolvedValue({ id: 'mk1' });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'skill', 'i', 'some-skill']);
|
||||
|
||||
expect(mockTrpcClient.agentSkills.importFromMarket.mutate).toHaveBeenCalledWith({
|
||||
identifier: 'some-skill',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectSourceType', () => {
|
||||
it('should detect GitHub URLs', () => {
|
||||
expect(detectSourceType('https://github.com/user/repo')).toBe('github');
|
||||
expect(detectSourceType('http://github.com/user/repo')).toBe('github');
|
||||
});
|
||||
|
||||
it('should detect GitHub shorthand', () => {
|
||||
expect(detectSourceType('lobehub/skill-repo')).toBe('github');
|
||||
expect(detectSourceType('user/repo-name')).toBe('github');
|
||||
});
|
||||
|
||||
it('should detect ZIP/other URLs', () => {
|
||||
expect(detectSourceType('https://example.com/skill.zip')).toBe('url');
|
||||
expect(detectSourceType('https://cdn.example.com/pkg')).toBe('url');
|
||||
});
|
||||
|
||||
it('should detect marketplace identifiers', () => {
|
||||
expect(detectSourceType('my-skill')).toBe('market');
|
||||
expect(detectSourceType('some-cool-skill')).toBe('market');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resources', () => {
|
||||
|
||||
@@ -5,6 +5,25 @@ import { getTrpcClient } from '../api/client';
|
||||
import { confirm, outputJson, printTable, truncate } from '../utils/format';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
type SourceType = 'github' | 'market' | 'url';
|
||||
|
||||
export function detectSourceType(source: string): SourceType {
|
||||
// GitHub URL: https://github.com/owner/repo
|
||||
if (source.startsWith('https://github.com/') || source.startsWith('http://github.com/')) {
|
||||
return 'github';
|
||||
}
|
||||
// GitHub shorthand: owner/repo (contains exactly one slash, no dots or colons)
|
||||
if (/^[\w-]+\/[\w.-]+$/.test(source)) {
|
||||
return 'github';
|
||||
}
|
||||
// Other URLs (ZIP, etc.)
|
||||
if (source.startsWith('https://') || source.startsWith('http://')) {
|
||||
return 'url';
|
||||
}
|
||||
// Marketplace identifier
|
||||
return 'market';
|
||||
}
|
||||
|
||||
export function registerSkillCommand(program: Command) {
|
||||
const skill = program.command('skill').description('Manage agent skills');
|
||||
|
||||
@@ -209,54 +228,40 @@ export function registerSkillCommand(program: Command) {
|
||||
printTable(rows, ['ID', 'NAME', 'DESCRIPTION']);
|
||||
});
|
||||
|
||||
// ── import-github ─────────────────────────────────────
|
||||
// ── install (alias: i) ───────────────────────────────────
|
||||
|
||||
skill
|
||||
.command('import-github')
|
||||
.description('Import a skill from GitHub')
|
||||
.requiredOption('--url <gitUrl>', 'GitHub repository URL')
|
||||
.option('--branch <branch>', 'Branch name')
|
||||
.action(async (options: { branch?: string; url: string }) => {
|
||||
.command('install <source>')
|
||||
.alias('i')
|
||||
.description(
|
||||
'Install a skill (auto-detects: GitHub URL/shorthand, ZIP URL, or marketplace identifier)',
|
||||
)
|
||||
.option('--branch <branch>', 'Branch name (GitHub only)')
|
||||
.action(async (source: string, options: { branch?: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
const sourceType = detectSourceType(source);
|
||||
|
||||
const input: { branch?: string; gitUrl: string } = { gitUrl: options.url };
|
||||
if (options.branch) input.branch = options.branch;
|
||||
if (sourceType === 'github') {
|
||||
const gitUrl = source.startsWith('https://') ? source : `https://github.com/${source}`;
|
||||
const input: { branch?: string; gitUrl: string } = { gitUrl };
|
||||
if (options.branch) input.branch = options.branch;
|
||||
|
||||
const result = await client.agentSkills.importFromGitHub.mutate(input);
|
||||
const r = result as any;
|
||||
console.log(`${pc.green('✓')} Imported skill from GitHub ${pc.bold(r.id || r.name || '')}`);
|
||||
});
|
||||
|
||||
// ── import-url ────────────────────────────────────────
|
||||
|
||||
skill
|
||||
.command('import-url')
|
||||
.description('Import a skill from a ZIP URL')
|
||||
.requiredOption('--url <zipUrl>', 'URL to skill ZIP file')
|
||||
.action(async (options: { url: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
const result = await client.agentSkills.importFromUrl.mutate({ url: options.url });
|
||||
const r = result as any;
|
||||
console.log(`${pc.green('✓')} Imported skill from URL ${pc.bold(r.id || r.name || '')}`);
|
||||
});
|
||||
|
||||
// ── import-market ─────────────────────────────────────
|
||||
|
||||
skill
|
||||
.command('import-market')
|
||||
.description('Install a skill from the marketplace')
|
||||
.requiredOption('-i, --identifier <id>', 'Skill identifier in marketplace')
|
||||
.action(async (options: { identifier: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
const result = await client.agentSkills.importFromMarket.mutate({
|
||||
identifier: options.identifier,
|
||||
});
|
||||
const r = result as any;
|
||||
console.log(
|
||||
`${pc.green('✓')} Installed skill ${pc.bold(options.identifier)} ${r.id ? `(${r.id})` : ''}`,
|
||||
);
|
||||
const result = await client.agentSkills.importFromGitHub.mutate(input);
|
||||
const r = result as any;
|
||||
console.log(
|
||||
`${pc.green('✓')} Installed skill from GitHub ${pc.bold(r.id || r.name || '')}`,
|
||||
);
|
||||
} else if (sourceType === 'url') {
|
||||
const result = await client.agentSkills.importFromUrl.mutate({ url: source });
|
||||
const r = result as any;
|
||||
console.log(`${pc.green('✓')} Installed skill from URL ${pc.bold(r.id || r.name || '')}`);
|
||||
} else {
|
||||
const result = await client.agentSkills.importFromMarket.mutate({ identifier: source });
|
||||
const r = result as any;
|
||||
console.log(
|
||||
`${pc.green('✓')} Installed skill ${pc.bold(source)} ${r.id ? `(${r.id})` : ''}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// ── resources ─────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { registerThreadCommand } from './thread';
|
||||
|
||||
const { mockTrpcClient } = vi.hoisted(() => ({
|
||||
mockTrpcClient: {
|
||||
thread: {
|
||||
getThread: { query: vi.fn() },
|
||||
getThreads: { query: vi.fn() },
|
||||
removeThread: { mutate: vi.fn() },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const { getTrpcClient: mockGetTrpcClient } = vi.hoisted(() => ({
|
||||
getTrpcClient: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../api/client', () => ({ getTrpcClient: mockGetTrpcClient }));
|
||||
vi.mock('../utils/logger', () => ({
|
||||
log: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() },
|
||||
setVerbose: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('thread command', () => {
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
|
||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
mockGetTrpcClient.mockResolvedValue(mockTrpcClient);
|
||||
for (const method of Object.values(mockTrpcClient.thread)) {
|
||||
for (const fn of Object.values(method)) {
|
||||
(fn as ReturnType<typeof vi.fn>).mockReset();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
exitSpy.mockRestore();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
function createProgram() {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerThreadCommand(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
describe('list', () => {
|
||||
it('should list threads by topic', async () => {
|
||||
mockTrpcClient.thread.getThreads.query.mockResolvedValue([
|
||||
{ id: 't1', title: 'Thread 1', type: 'standalone' },
|
||||
]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'thread', 'list', '--topic-id', 'topic1']);
|
||||
|
||||
expect(mockTrpcClient.thread.getThreads.query).toHaveBeenCalledWith({ topicId: 'topic1' });
|
||||
});
|
||||
|
||||
it('should show empty message when no threads', async () => {
|
||||
mockTrpcClient.thread.getThreads.query.mockResolvedValue([]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'thread', 'list', '--topic-id', 'topic1']);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith('No threads found.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('list-all', () => {
|
||||
it('should list all threads', async () => {
|
||||
mockTrpcClient.thread.getThread.query.mockResolvedValue([
|
||||
{ id: 't1', title: 'Thread 1', type: 'standalone' },
|
||||
]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'thread', 'list-all']);
|
||||
|
||||
expect(mockTrpcClient.thread.getThread.query).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete a thread', async () => {
|
||||
mockTrpcClient.thread.removeThread.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'thread', 'delete', 't1', '--yes']);
|
||||
|
||||
expect(mockTrpcClient.thread.removeThread.mutate).toHaveBeenCalledWith({
|
||||
id: 't1',
|
||||
removeChildren: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should delete with remove-children flag', async () => {
|
||||
mockTrpcClient.thread.removeThread.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'thread',
|
||||
'delete',
|
||||
't1',
|
||||
'--remove-children',
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.thread.removeThread.mutate).toHaveBeenCalledWith({
|
||||
id: 't1',
|
||||
removeChildren: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../api/client';
|
||||
import { confirm, outputJson, printTable, timeAgo, truncate } from '../utils/format';
|
||||
|
||||
export function registerThreadCommand(program: Command) {
|
||||
const thread = program.command('thread').description('Manage message threads');
|
||||
|
||||
// ── list ──────────────────────────────────────────────
|
||||
|
||||
thread
|
||||
.command('list')
|
||||
.description('List threads by topic')
|
||||
.requiredOption('--topic-id <id>', 'Topic ID')
|
||||
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
|
||||
.action(async (options: { json?: string | boolean; topicId: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.thread.getThreads.query({ topicId: options.topicId });
|
||||
const items = Array.isArray(result) ? result : [];
|
||||
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(items, fields);
|
||||
return;
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
console.log('No threads found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = items.map((t: any) => [
|
||||
t.id || '',
|
||||
truncate(t.title || 'Untitled', 50),
|
||||
t.type || '',
|
||||
t.updatedAt ? timeAgo(t.updatedAt) : '',
|
||||
]);
|
||||
|
||||
printTable(rows, ['ID', 'TITLE', 'TYPE', 'UPDATED']);
|
||||
});
|
||||
|
||||
// ── list-all ──────────────────────────────────────────
|
||||
|
||||
thread
|
||||
.command('list-all')
|
||||
.description('List all threads for the current user')
|
||||
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
|
||||
.action(async (options: { json?: string | boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.thread.getThread.query();
|
||||
const items = Array.isArray(result) ? result : [];
|
||||
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(items, fields);
|
||||
return;
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
console.log('No threads found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = items.map((t: any) => [
|
||||
t.id || '',
|
||||
truncate(t.title || 'Untitled', 50),
|
||||
t.type || '',
|
||||
t.topicId || '',
|
||||
t.updatedAt ? timeAgo(t.updatedAt) : '',
|
||||
]);
|
||||
|
||||
printTable(rows, ['ID', 'TITLE', 'TYPE', 'TOPIC', 'UPDATED']);
|
||||
});
|
||||
|
||||
// ── delete ────────────────────────────────────────────
|
||||
|
||||
thread
|
||||
.command('delete <id>')
|
||||
.description('Delete a thread')
|
||||
.option('--remove-children', 'Also remove child messages')
|
||||
.option('--yes', 'Skip confirmation prompt')
|
||||
.action(async (id: string, options: { removeChildren?: boolean; yes?: boolean }) => {
|
||||
if (!options.yes) {
|
||||
const confirmed = await confirm('Are you sure you want to delete this thread?');
|
||||
if (!confirmed) {
|
||||
console.log('Cancelled.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
await client.thread.removeThread.mutate({
|
||||
id,
|
||||
removeChildren: options.removeChildren,
|
||||
});
|
||||
console.log(`${pc.green('✓')} Deleted thread ${pc.bold(id)}`);
|
||||
});
|
||||
}
|
||||
+114
-21
@@ -14,7 +14,6 @@ export function registerTopicCommand(program: Command) {
|
||||
.command('list')
|
||||
.description('List topics')
|
||||
.option('--agent-id <id>', 'Filter by agent ID')
|
||||
.option('--session-id <id>', 'Filter by session ID')
|
||||
.option('-L, --limit <n>', 'Page size', '30')
|
||||
.option('--page <n>', 'Page number', '1')
|
||||
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
|
||||
@@ -24,13 +23,11 @@ export function registerTopicCommand(program: Command) {
|
||||
json?: string | boolean;
|
||||
limit?: string;
|
||||
page?: string;
|
||||
sessionId?: string;
|
||||
}) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
const input: Record<string, any> = {};
|
||||
if (options.agentId) input.agentId = options.agentId;
|
||||
if (options.sessionId) input.sessionId = options.sessionId;
|
||||
if (options.limit) input.pageSize = Number.parseInt(options.limit, 10);
|
||||
if (options.page) input.current = Number.parseInt(options.page, 10);
|
||||
|
||||
@@ -98,27 +95,18 @@ export function registerTopicCommand(program: Command) {
|
||||
.description('Create a topic')
|
||||
.requiredOption('-t, --title <title>', 'Topic title')
|
||||
.option('--agent-id <id>', 'Agent ID')
|
||||
.option('--session-id <id>', 'Session ID')
|
||||
.option('--favorite', 'Mark as favorite')
|
||||
.action(
|
||||
async (options: {
|
||||
agentId?: string;
|
||||
favorite?: boolean;
|
||||
sessionId?: string;
|
||||
title: string;
|
||||
}) => {
|
||||
const client = await getTrpcClient();
|
||||
.action(async (options: { agentId?: string; favorite?: boolean; title: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
const input: Record<string, any> = { title: options.title };
|
||||
if (options.agentId) input.agentId = options.agentId;
|
||||
if (options.sessionId) input.sessionId = options.sessionId;
|
||||
if (options.favorite) input.favorite = true;
|
||||
const input: Record<string, any> = { title: options.title };
|
||||
if (options.agentId) input.agentId = options.agentId;
|
||||
if (options.favorite) input.favorite = true;
|
||||
|
||||
const result = await client.topic.createTopic.mutate(input as any);
|
||||
const r = result as any;
|
||||
console.log(`${pc.green('✓')} Created topic ${pc.bold(r.id || r)}`);
|
||||
},
|
||||
);
|
||||
const result = await client.topic.createTopic.mutate(input as any);
|
||||
const r = result as any;
|
||||
console.log(`${pc.green('✓')} Created topic ${pc.bold(r.id || r)}`);
|
||||
});
|
||||
|
||||
// ── edit ──────────────────────────────────────────────
|
||||
|
||||
@@ -169,6 +157,111 @@ export function registerTopicCommand(program: Command) {
|
||||
console.log(`${pc.green('✓')} Deleted ${ids.length} topic(s)`);
|
||||
});
|
||||
|
||||
// ── clone ───────────────────────────────────────────
|
||||
|
||||
topic
|
||||
.command('clone <id>')
|
||||
.description('Clone a topic')
|
||||
.option('-t, --title <title>', 'New title for the cloned topic')
|
||||
.action(async (id: string, options: { title?: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
const input: Record<string, any> = { id };
|
||||
if (options.title) input.newTitle = options.title;
|
||||
|
||||
const newId = await client.topic.cloneTopic.mutate(input as any);
|
||||
console.log(`${pc.green('✓')} Cloned topic → ${pc.bold(String(newId || ''))}`);
|
||||
});
|
||||
|
||||
// ── share ──────────────────────────────────────────
|
||||
|
||||
topic
|
||||
.command('share <id>')
|
||||
.description('Enable sharing for a topic')
|
||||
.option('--visibility <v>', 'Visibility: private or link', 'link')
|
||||
.action(async (id: string, options: { visibility?: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
const input: Record<string, any> = { topicId: id };
|
||||
if (options.visibility) input.visibility = options.visibility;
|
||||
|
||||
const result = await client.topic.enableSharing.mutate(input as any);
|
||||
const r = result as any;
|
||||
|
||||
console.log(`${pc.green('✓')} Sharing enabled for topic ${pc.bold(id)}`);
|
||||
if (r.shareId) {
|
||||
console.log(` Share ID: ${pc.bold(r.shareId)}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── unshare ────────────────────────────────────────
|
||||
|
||||
topic
|
||||
.command('unshare <id>')
|
||||
.description('Disable sharing for a topic')
|
||||
.action(async (id: string) => {
|
||||
const client = await getTrpcClient();
|
||||
await client.topic.disableSharing.mutate({ topicId: id });
|
||||
console.log(`${pc.green('✓')} Sharing disabled for topic ${pc.bold(id)}`);
|
||||
});
|
||||
|
||||
// ── share-info ─────────────────────────────────────
|
||||
|
||||
topic
|
||||
.command('share-info <id>')
|
||||
.description('View sharing info for a topic')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(async (id: string, options: { json?: boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const info = await client.topic.getShareInfo.query({ topicId: id });
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(info, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!info) {
|
||||
console.log('Sharing not enabled for this topic.');
|
||||
return;
|
||||
}
|
||||
|
||||
const i = info as any;
|
||||
console.log(`${pc.bold('Topic ID:')} ${id}`);
|
||||
if (i.shareId) console.log(`${pc.bold('Share ID:')} ${i.shareId}`);
|
||||
if (i.visibility) console.log(`${pc.bold('Visibility:')} ${i.visibility}`);
|
||||
if (i.createdAt) console.log(`${pc.bold('Created:')} ${i.createdAt}`);
|
||||
});
|
||||
|
||||
// ── import ─────────────────────────────────────────
|
||||
|
||||
topic
|
||||
.command('import')
|
||||
.description('Import a topic')
|
||||
.requiredOption('--agent-id <id>', 'Agent ID')
|
||||
.requiredOption('--data <json>', 'Topic data as JSON string')
|
||||
.option('--group-id <id>', 'Group ID')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(
|
||||
async (options: { agentId: string; data: string; groupId?: string; json?: boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
const input: Record<string, any> = {
|
||||
agentId: options.agentId,
|
||||
data: options.data,
|
||||
};
|
||||
if (options.groupId) input.groupId = options.groupId;
|
||||
|
||||
const result = await client.topic.importTopic.mutate(input as any);
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`${pc.green('✓')} Topic imported successfully`);
|
||||
},
|
||||
);
|
||||
|
||||
// ── recent ────────────────────────────────────────────
|
||||
|
||||
topic
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { log } from '../utils/logger';
|
||||
import { registerUserCommand } from './user';
|
||||
|
||||
const { mockTrpcClient } = vi.hoisted(() => ({
|
||||
mockTrpcClient: {
|
||||
user: {
|
||||
getUserRegistrationDuration: { query: vi.fn() },
|
||||
updateAvatar: { mutate: vi.fn() },
|
||||
updateFullName: { mutate: vi.fn() },
|
||||
updatePreference: { mutate: vi.fn() },
|
||||
updateSettings: { mutate: vi.fn() },
|
||||
updateUsername: { mutate: vi.fn() },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const { getTrpcClient: mockGetTrpcClient } = vi.hoisted(() => ({
|
||||
getTrpcClient: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../api/client', () => ({ getTrpcClient: mockGetTrpcClient }));
|
||||
vi.mock('../utils/logger', () => ({
|
||||
log: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() },
|
||||
setVerbose: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('user command', () => {
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
|
||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
mockGetTrpcClient.mockResolvedValue(mockTrpcClient);
|
||||
for (const method of Object.values(mockTrpcClient.user)) {
|
||||
for (const fn of Object.values(method)) {
|
||||
(fn as ReturnType<typeof vi.fn>).mockReset();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
exitSpy.mockRestore();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
function createProgram() {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerUserCommand(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
describe('info', () => {
|
||||
it('should display registration duration', async () => {
|
||||
const durationMs = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
mockTrpcClient.user.getUserRegistrationDuration.query.mockResolvedValue(durationMs);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'user', 'info']);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('30'));
|
||||
});
|
||||
|
||||
it('should output JSON', async () => {
|
||||
mockTrpcClient.user.getUserRegistrationDuration.query.mockResolvedValue(86400000);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'user', 'info', '--json']);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(86400000, null, 2));
|
||||
});
|
||||
});
|
||||
|
||||
describe('settings', () => {
|
||||
it('should update settings', async () => {
|
||||
mockTrpcClient.user.updateSettings.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'user', 'settings', '--data', '{"language":"en"}']);
|
||||
|
||||
expect(mockTrpcClient.user.updateSettings.mutate).toHaveBeenCalledWith({ language: 'en' });
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Settings updated'));
|
||||
});
|
||||
|
||||
it('should reject invalid JSON', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'user', 'settings', '--data', 'not-json']);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith('Invalid settings JSON.');
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('preferences', () => {
|
||||
it('should update preferences', async () => {
|
||||
mockTrpcClient.user.updatePreference.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'user',
|
||||
'preferences',
|
||||
'--data',
|
||||
'{"theme":"dark"}',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.user.updatePreference.mutate).toHaveBeenCalledWith({ theme: 'dark' });
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Preferences updated'));
|
||||
});
|
||||
|
||||
it('should reject invalid JSON', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'user', 'preferences', '--data', '{bad}']);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith('Invalid preferences JSON.');
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update-avatar', () => {
|
||||
it('should update avatar', async () => {
|
||||
mockTrpcClient.user.updateAvatar.mutate.mockResolvedValue({ avatar: 'new-url' });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'user',
|
||||
'update-avatar',
|
||||
'https://example.com/avatar.png',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.user.updateAvatar.mutate).toHaveBeenCalledWith(
|
||||
'https://example.com/avatar.png',
|
||||
);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Avatar updated'));
|
||||
});
|
||||
|
||||
it('should output JSON', async () => {
|
||||
const result = { avatar: 'new-url' };
|
||||
mockTrpcClient.user.updateAvatar.mutate.mockResolvedValue(result);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'user',
|
||||
'update-avatar',
|
||||
'https://example.com/avatar.png',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(result, null, 2));
|
||||
});
|
||||
});
|
||||
|
||||
describe('update-name', () => {
|
||||
it('should update full name', async () => {
|
||||
mockTrpcClient.user.updateFullName.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'user', 'update-name', '--full-name', 'John Doe']);
|
||||
|
||||
expect(mockTrpcClient.user.updateFullName.mutate).toHaveBeenCalledWith('John Doe');
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Full name updated'));
|
||||
});
|
||||
|
||||
it('should update username', async () => {
|
||||
mockTrpcClient.user.updateUsername.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'user', 'update-name', '--username', 'johndoe']);
|
||||
|
||||
expect(mockTrpcClient.user.updateUsername.mutate).toHaveBeenCalledWith('johndoe');
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Username updated'));
|
||||
});
|
||||
|
||||
it('should error when no changes specified', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'user', 'update-name']);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('No changes'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../api/client';
|
||||
import { outputJson } from '../utils/format';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
export function registerUserCommand(program: Command) {
|
||||
const user = program.command('user').description('Manage user account and settings');
|
||||
|
||||
// ── info ──────────────────────────────────────────────
|
||||
|
||||
user
|
||||
.command('info')
|
||||
.description('View user registration info')
|
||||
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
|
||||
.action(async (options: { json?: string | boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.user.getUserRegistrationDuration.query();
|
||||
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(result, fields);
|
||||
return;
|
||||
}
|
||||
|
||||
const r = result as any;
|
||||
if (typeof r === 'number') {
|
||||
const days = Math.floor(r / (1000 * 60 * 60 * 24));
|
||||
console.log(`Registered for ${pc.bold(String(days))} day(s).`);
|
||||
} else {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
});
|
||||
|
||||
// ── settings ──────────────────────────────────────────
|
||||
|
||||
user
|
||||
.command('settings')
|
||||
.description('Update user settings')
|
||||
.requiredOption('--data <json>', 'Settings JSON')
|
||||
.action(async (options: { data: string }) => {
|
||||
let data: any;
|
||||
try {
|
||||
data = JSON.parse(options.data);
|
||||
} catch {
|
||||
log.error('Invalid settings JSON.');
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
await client.user.updateSettings.mutate(data);
|
||||
console.log(`${pc.green('✓')} Settings updated.`);
|
||||
});
|
||||
|
||||
// ── preferences ───────────────────────────────────────
|
||||
|
||||
user
|
||||
.command('preferences')
|
||||
.description('Update user preferences')
|
||||
.requiredOption('--data <json>', 'Preferences JSON')
|
||||
.action(async (options: { data: string }) => {
|
||||
let data: any;
|
||||
try {
|
||||
data = JSON.parse(options.data);
|
||||
} catch {
|
||||
log.error('Invalid preferences JSON.');
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
await client.user.updatePreference.mutate(data);
|
||||
console.log(`${pc.green('✓')} Preferences updated.`);
|
||||
});
|
||||
|
||||
// ── update-avatar ─────────────────────────────────────
|
||||
|
||||
user
|
||||
.command('update-avatar <url>')
|
||||
.description('Update user avatar (URL or Base64)')
|
||||
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
|
||||
.action(async (url: string, options: { json?: string | boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.user.updateAvatar.mutate(url);
|
||||
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(result, fields);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`${pc.green('✓')} Avatar updated.`);
|
||||
});
|
||||
|
||||
// ── update-name ───────────────────────────────────────
|
||||
|
||||
user
|
||||
.command('update-name')
|
||||
.description('Update user full name or username')
|
||||
.option('--full-name <name>', 'Update full name (max 64 chars)')
|
||||
.option('--username <name>', 'Update username (alphanumeric + underscore)')
|
||||
.action(async (options: { fullName?: string; username?: string }) => {
|
||||
if (!options.fullName && !options.username) {
|
||||
log.error('No changes specified. Use --full-name or --username.');
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
|
||||
if (options.fullName) {
|
||||
await client.user.updateFullName.mutate(options.fullName);
|
||||
console.log(`${pc.green('✓')} Full name updated to ${pc.bold(options.fullName)}`);
|
||||
}
|
||||
|
||||
if (options.username) {
|
||||
await client.user.updateUsername.mutate(options.username);
|
||||
console.log(`${pc.green('✓')} Username updated to ${pc.bold(options.username)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
+21
-2
@@ -1,13 +1,19 @@
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
import { Command } from 'commander';
|
||||
|
||||
import { registerAgentCommand } from './commands/agent';
|
||||
import { registerAgentGroupCommand } from './commands/agent-group';
|
||||
import { registerBotCommand } from './commands/bot';
|
||||
import { registerConfigCommand } from './commands/config';
|
||||
import { registerConnectCommand } from './commands/connect';
|
||||
import { registerCronCommand } from './commands/cron';
|
||||
import { registerDeviceCommand } from './commands/device';
|
||||
import { registerDocCommand } from './commands/doc';
|
||||
import { registerEvalCommand } from './commands/eval';
|
||||
import { registerFileCommand } from './commands/file';
|
||||
import { registerGenerateCommand } from './commands/generate';
|
||||
import { registerKbCommand } from './commands/kb';
|
||||
import { registerEvalCommand } from './commands/eval';
|
||||
import { registerLoginCommand } from './commands/login';
|
||||
import { registerLogoutCommand } from './commands/logout';
|
||||
import { registerMemoryCommand } from './commands/memory';
|
||||
@@ -16,34 +22,47 @@ import { registerModelCommand } from './commands/model';
|
||||
import { registerPluginCommand } from './commands/plugin';
|
||||
import { registerProviderCommand } from './commands/provider';
|
||||
import { registerSearchCommand } from './commands/search';
|
||||
import { registerSessionGroupCommand } from './commands/session-group';
|
||||
import { registerSkillCommand } from './commands/skill';
|
||||
import { registerStatusCommand } from './commands/status';
|
||||
import { registerThreadCommand } from './commands/thread';
|
||||
import { registerTopicCommand } from './commands/topic';
|
||||
import { registerUserCommand } from './commands/user';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { version } = require('../package.json');
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name('lh')
|
||||
.description('LobeHub CLI - manage and connect to LobeHub services')
|
||||
.version('0.1.0');
|
||||
.version(version);
|
||||
|
||||
registerLoginCommand(program);
|
||||
registerLogoutCommand(program);
|
||||
registerConnectCommand(program);
|
||||
registerDeviceCommand(program);
|
||||
registerStatusCommand(program);
|
||||
registerDocCommand(program);
|
||||
registerSearchCommand(program);
|
||||
registerKbCommand(program);
|
||||
registerMemoryCommand(program);
|
||||
registerAgentCommand(program);
|
||||
registerAgentGroupCommand(program);
|
||||
registerBotCommand(program);
|
||||
registerCronCommand(program);
|
||||
registerGenerateCommand(program);
|
||||
registerFileCommand(program);
|
||||
registerSkillCommand(program);
|
||||
registerSessionGroupCommand(program);
|
||||
registerThreadCommand(program);
|
||||
registerTopicCommand(program);
|
||||
registerMessageCommand(program);
|
||||
registerModelCommand(program);
|
||||
registerProviderCommand(program);
|
||||
registerPluginCommand(program);
|
||||
registerUserCommand(program);
|
||||
registerConfigCommand(program);
|
||||
registerEvalCommand(program);
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@ export interface StoredSettings {
|
||||
serverUrl?: string;
|
||||
}
|
||||
|
||||
const SETTINGS_DIR = path.join(os.homedir(), '.lobehub');
|
||||
const LOBEHUB_DIR_NAME = process.env.LOBEHUB_CLI_HOME || '.lobehub';
|
||||
const SETTINGS_DIR = path.join(os.homedir(), LOBEHUB_DIR_NAME);
|
||||
const SETTINGS_FILE = path.join(SETTINGS_DIR, 'settings.json');
|
||||
|
||||
function normalizeUrl(url: string | undefined): string | undefined {
|
||||
|
||||
+40
-393
@@ -24,7 +24,7 @@ vi.mock('../utils/logger', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
describe('file tools', () => {
|
||||
describe('file tools (integration wrapper)', () => {
|
||||
const tmpDir = path.join(os.tmpdir(), 'cli-file-test-' + process.pid);
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -35,424 +35,71 @@ describe('file tools', () => {
|
||||
fs.rmSync(tmpDir, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
describe('readLocalFile', () => {
|
||||
it('should read a file with default line range (0-200)', async () => {
|
||||
const filePath = path.join(tmpDir, 'test.txt');
|
||||
const lines = Array.from({ length: 300 }, (_, i) => `line ${i}`);
|
||||
await writeFile(filePath, lines.join('\n'));
|
||||
it('should re-export readLocalFile from shared package', async () => {
|
||||
const filePath = path.join(tmpDir, 'test.txt');
|
||||
await writeFile(filePath, 'hello world');
|
||||
|
||||
const result = await readLocalFile({ path: filePath });
|
||||
const result = await readLocalFile({ path: filePath });
|
||||
|
||||
expect(result.lineCount).toBe(200);
|
||||
expect(result.totalLineCount).toBe(300);
|
||||
expect(result.loc).toEqual([0, 200]);
|
||||
expect(result.filename).toBe('test.txt');
|
||||
expect(result.fileType).toBe('txt');
|
||||
});
|
||||
|
||||
it('should read full content when fullContent is true', async () => {
|
||||
const filePath = path.join(tmpDir, 'full.txt');
|
||||
const lines = Array.from({ length: 300 }, (_, i) => `line ${i}`);
|
||||
await writeFile(filePath, lines.join('\n'));
|
||||
|
||||
const result = await readLocalFile({ fullContent: true, path: filePath });
|
||||
|
||||
expect(result.lineCount).toBe(300);
|
||||
expect(result.loc).toEqual([0, 300]);
|
||||
});
|
||||
|
||||
it('should read specific line range', async () => {
|
||||
const filePath = path.join(tmpDir, 'range.txt');
|
||||
const lines = Array.from({ length: 10 }, (_, i) => `line ${i}`);
|
||||
await writeFile(filePath, lines.join('\n'));
|
||||
|
||||
const result = await readLocalFile({ loc: [2, 5], path: filePath });
|
||||
|
||||
expect(result.lineCount).toBe(3);
|
||||
expect(result.content).toBe('line 2\nline 3\nline 4');
|
||||
expect(result.loc).toEqual([2, 5]);
|
||||
});
|
||||
|
||||
it('should handle non-existent file', async () => {
|
||||
const result = await readLocalFile({ path: path.join(tmpDir, 'nope.txt') });
|
||||
|
||||
expect(result.content).toContain('Error');
|
||||
expect(result.lineCount).toBe(0);
|
||||
expect(result.totalLineCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should detect file type from extension', async () => {
|
||||
const filePath = path.join(tmpDir, 'code.ts');
|
||||
await writeFile(filePath, 'const x = 1;');
|
||||
|
||||
const result = await readLocalFile({ path: filePath });
|
||||
|
||||
expect(result.fileType).toBe('ts');
|
||||
});
|
||||
|
||||
it('should handle file without extension', async () => {
|
||||
const filePath = path.join(tmpDir, 'Makefile');
|
||||
await writeFile(filePath, 'all: build');
|
||||
|
||||
const result = await readLocalFile({ path: filePath });
|
||||
|
||||
expect(result.fileType).toBe('unknown');
|
||||
});
|
||||
expect(result.filename).toBe('test.txt');
|
||||
expect(result.content).toBe('hello world');
|
||||
});
|
||||
|
||||
describe('writeLocalFile', () => {
|
||||
it('should write a file successfully', async () => {
|
||||
const filePath = path.join(tmpDir, 'output.txt');
|
||||
it('should re-export writeLocalFile from shared package', async () => {
|
||||
const filePath = path.join(tmpDir, 'output.txt');
|
||||
|
||||
const result = await writeLocalFile({ content: 'hello world', path: filePath });
|
||||
const result = await writeLocalFile({ content: 'written', path: filePath });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(fs.readFileSync(filePath, 'utf8')).toBe('hello world');
|
||||
});
|
||||
|
||||
it('should create parent directories', async () => {
|
||||
const filePath = path.join(tmpDir, 'sub', 'dir', 'file.txt');
|
||||
|
||||
const result = await writeLocalFile({ content: 'nested', path: filePath });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(fs.readFileSync(filePath, 'utf8')).toBe('nested');
|
||||
});
|
||||
|
||||
it('should return error for empty path', async () => {
|
||||
const result = await writeLocalFile({ content: 'data', path: '' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Path cannot be empty');
|
||||
});
|
||||
|
||||
it('should return error for undefined content', async () => {
|
||||
const result = await writeLocalFile({
|
||||
content: undefined as any,
|
||||
path: path.join(tmpDir, 'f.txt'),
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Content cannot be empty');
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(fs.readFileSync(filePath, 'utf8')).toBe('written');
|
||||
});
|
||||
|
||||
describe('editLocalFile', () => {
|
||||
it('should replace first occurrence by default', async () => {
|
||||
const filePath = path.join(tmpDir, 'edit.txt');
|
||||
await writeFile(filePath, 'hello world\nhello again');
|
||||
it('should re-export editLocalFile from shared package', async () => {
|
||||
const filePath = path.join(tmpDir, 'edit.txt');
|
||||
await writeFile(filePath, 'hello world');
|
||||
|
||||
const result = await editLocalFile({
|
||||
file_path: filePath,
|
||||
new_string: 'hi',
|
||||
old_string: 'hello',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.replacements).toBe(1);
|
||||
expect(fs.readFileSync(filePath, 'utf8')).toBe('hi world\nhello again');
|
||||
expect(result.diffText).toBeDefined();
|
||||
expect(result.linesAdded).toBeDefined();
|
||||
expect(result.linesDeleted).toBeDefined();
|
||||
const result = await editLocalFile({
|
||||
file_path: filePath,
|
||||
new_string: 'hi',
|
||||
old_string: 'hello',
|
||||
});
|
||||
|
||||
it('should replace all occurrences when replace_all is true', async () => {
|
||||
const filePath = path.join(tmpDir, 'edit-all.txt');
|
||||
await writeFile(filePath, 'hello world\nhello again');
|
||||
|
||||
const result = await editLocalFile({
|
||||
file_path: filePath,
|
||||
new_string: 'hi',
|
||||
old_string: 'hello',
|
||||
replace_all: true,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.replacements).toBe(2);
|
||||
expect(fs.readFileSync(filePath, 'utf8')).toBe('hi world\nhi again');
|
||||
});
|
||||
|
||||
it('should return error when old_string not found', async () => {
|
||||
const filePath = path.join(tmpDir, 'no-match.txt');
|
||||
await writeFile(filePath, 'hello world');
|
||||
|
||||
const result = await editLocalFile({
|
||||
file_path: filePath,
|
||||
new_string: 'hi',
|
||||
old_string: 'xyz',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.replacements).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle special regex characters in old_string with replace_all', async () => {
|
||||
const filePath = path.join(tmpDir, 'regex.txt');
|
||||
await writeFile(filePath, 'price is $10.00 and $20.00');
|
||||
|
||||
const result = await editLocalFile({
|
||||
file_path: filePath,
|
||||
new_string: '$XX.XX',
|
||||
old_string: '$10.00',
|
||||
replace_all: true,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(fs.readFileSync(filePath, 'utf8')).toBe('price is $XX.XX and $20.00');
|
||||
});
|
||||
|
||||
it('should handle file read error', async () => {
|
||||
const result = await editLocalFile({
|
||||
file_path: path.join(tmpDir, 'nonexistent.txt'),
|
||||
new_string: 'new',
|
||||
old_string: 'old',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.replacements).toBe(1);
|
||||
});
|
||||
|
||||
describe('listLocalFiles', () => {
|
||||
it('should list files in directory', async () => {
|
||||
await writeFile(path.join(tmpDir, 'a.txt'), 'a');
|
||||
await writeFile(path.join(tmpDir, 'b.txt'), 'b');
|
||||
await mkdir(path.join(tmpDir, 'subdir'));
|
||||
it('should re-export listLocalFiles from shared package', async () => {
|
||||
await writeFile(path.join(tmpDir, 'a.txt'), 'a');
|
||||
|
||||
const result = await listLocalFiles({ path: tmpDir });
|
||||
const result = await listLocalFiles({ path: tmpDir });
|
||||
|
||||
expect(result.totalCount).toBe(3);
|
||||
expect(result.files.length).toBe(3);
|
||||
const names = result.files.map((f: any) => f.name);
|
||||
expect(names).toContain('a.txt');
|
||||
expect(names).toContain('b.txt');
|
||||
expect(names).toContain('subdir');
|
||||
});
|
||||
|
||||
it('should sort by name ascending', async () => {
|
||||
await writeFile(path.join(tmpDir, 'c.txt'), 'c');
|
||||
await writeFile(path.join(tmpDir, 'a.txt'), 'a');
|
||||
await writeFile(path.join(tmpDir, 'b.txt'), 'b');
|
||||
|
||||
const result = await listLocalFiles({
|
||||
path: tmpDir,
|
||||
sortBy: 'name',
|
||||
sortOrder: 'asc',
|
||||
});
|
||||
|
||||
expect(result.files[0].name).toBe('a.txt');
|
||||
expect(result.files[2].name).toBe('c.txt');
|
||||
});
|
||||
|
||||
it('should sort by size', async () => {
|
||||
await writeFile(path.join(tmpDir, 'small.txt'), 'x');
|
||||
await writeFile(path.join(tmpDir, 'large.txt'), 'x'.repeat(1000));
|
||||
|
||||
const result = await listLocalFiles({
|
||||
path: tmpDir,
|
||||
sortBy: 'size',
|
||||
sortOrder: 'asc',
|
||||
});
|
||||
|
||||
expect(result.files[0].name).toBe('small.txt');
|
||||
});
|
||||
|
||||
it('should sort by createdTime', async () => {
|
||||
await writeFile(path.join(tmpDir, 'first.txt'), 'first');
|
||||
// Small delay to ensure different timestamps
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
await writeFile(path.join(tmpDir, 'second.txt'), 'second');
|
||||
|
||||
const result = await listLocalFiles({
|
||||
path: tmpDir,
|
||||
sortBy: 'createdTime',
|
||||
sortOrder: 'asc',
|
||||
});
|
||||
|
||||
expect(result.files.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should respect limit', async () => {
|
||||
await writeFile(path.join(tmpDir, 'a.txt'), 'a');
|
||||
await writeFile(path.join(tmpDir, 'b.txt'), 'b');
|
||||
await writeFile(path.join(tmpDir, 'c.txt'), 'c');
|
||||
|
||||
const result = await listLocalFiles({ limit: 2, path: tmpDir });
|
||||
|
||||
expect(result.files.length).toBe(2);
|
||||
expect(result.totalCount).toBe(3);
|
||||
});
|
||||
|
||||
it('should handle non-existent directory', async () => {
|
||||
const result = await listLocalFiles({ path: path.join(tmpDir, 'nope') });
|
||||
|
||||
expect(result.files).toEqual([]);
|
||||
expect(result.totalCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should use default sortBy for unknown sort key', async () => {
|
||||
await writeFile(path.join(tmpDir, 'a.txt'), 'a');
|
||||
|
||||
const result = await listLocalFiles({
|
||||
path: tmpDir,
|
||||
sortBy: 'unknown' as any,
|
||||
});
|
||||
|
||||
expect(result.files.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should mark directories correctly', async () => {
|
||||
await mkdir(path.join(tmpDir, 'mydir'));
|
||||
|
||||
const result = await listLocalFiles({ path: tmpDir });
|
||||
|
||||
const dir = result.files.find((f: any) => f.name === 'mydir');
|
||||
expect(dir.isDirectory).toBe(true);
|
||||
expect(dir.type).toBe('directory');
|
||||
});
|
||||
expect(result.totalCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
describe('globLocalFiles', () => {
|
||||
it('should match glob patterns', async () => {
|
||||
await writeFile(path.join(tmpDir, 'a.ts'), 'a');
|
||||
await writeFile(path.join(tmpDir, 'b.ts'), 'b');
|
||||
await writeFile(path.join(tmpDir, 'c.js'), 'c');
|
||||
it('should re-export globLocalFiles from shared package', async () => {
|
||||
await writeFile(path.join(tmpDir, 'a.ts'), 'a');
|
||||
await writeFile(path.join(tmpDir, 'b.js'), 'b');
|
||||
|
||||
const result = await globLocalFiles({ cwd: tmpDir, pattern: '*.ts' });
|
||||
const result = await globLocalFiles({ cwd: tmpDir, pattern: '*.ts' });
|
||||
|
||||
expect(result.files.length).toBe(2);
|
||||
expect(result.files).toContain('a.ts');
|
||||
expect(result.files).toContain('b.ts');
|
||||
});
|
||||
|
||||
it('should ignore node_modules and .git', async () => {
|
||||
await mkdir(path.join(tmpDir, 'node_modules', 'pkg'), { recursive: true });
|
||||
await writeFile(path.join(tmpDir, 'node_modules', 'pkg', 'index.ts'), 'x');
|
||||
await writeFile(path.join(tmpDir, 'src.ts'), 'y');
|
||||
|
||||
const result = await globLocalFiles({ cwd: tmpDir, pattern: '**/*.ts' });
|
||||
|
||||
expect(result.files).toEqual(['src.ts']);
|
||||
});
|
||||
|
||||
it('should use process.cwd() when cwd not specified', async () => {
|
||||
const result = await globLocalFiles({ pattern: '*.nonexistent-ext-xyz' });
|
||||
|
||||
expect(result.files).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle invalid pattern gracefully', async () => {
|
||||
// fast-glob handles most patterns; test with a simple one
|
||||
const result = await globLocalFiles({ cwd: tmpDir, pattern: '*.txt' });
|
||||
|
||||
expect(result.files).toEqual([]);
|
||||
});
|
||||
expect(result.files).toContain('a.ts');
|
||||
expect(result.files).not.toContain('b.js');
|
||||
});
|
||||
|
||||
describe('editLocalFile edge cases', () => {
|
||||
it('should count lines added and deleted', async () => {
|
||||
const filePath = path.join(tmpDir, 'multiline.txt');
|
||||
await writeFile(filePath, 'line1\nline2\nline3');
|
||||
it('should re-export grepContent from shared package', async () => {
|
||||
await writeFile(path.join(tmpDir, 'search.txt'), 'hello world');
|
||||
|
||||
const result = await editLocalFile({
|
||||
file_path: filePath,
|
||||
new_string: 'newA\nnewB\nnewC\nnewD',
|
||||
old_string: 'line2',
|
||||
});
|
||||
const result = await grepContent({ cwd: tmpDir, pattern: 'hello' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.linesAdded).toBeGreaterThan(0);
|
||||
expect(result.linesDeleted).toBeGreaterThan(0);
|
||||
});
|
||||
expect(result).toHaveProperty('success');
|
||||
expect(result).toHaveProperty('matches');
|
||||
});
|
||||
|
||||
describe('grepContent', () => {
|
||||
it('should return matches using ripgrep', async () => {
|
||||
await writeFile(path.join(tmpDir, 'search.txt'), 'hello world\nfoo bar\nhello again');
|
||||
it('should re-export searchLocalFiles from shared package', async () => {
|
||||
await writeFile(path.join(tmpDir, 'config.json'), '{}');
|
||||
|
||||
const result = await grepContent({ cwd: tmpDir, pattern: 'hello' });
|
||||
const result = await searchLocalFiles({ directory: tmpDir, keywords: 'config' });
|
||||
|
||||
// Result depends on whether rg is installed
|
||||
expect(result).toHaveProperty('success');
|
||||
expect(result).toHaveProperty('matches');
|
||||
});
|
||||
|
||||
it('should support file pattern filter', async () => {
|
||||
await writeFile(path.join(tmpDir, 'test.ts'), 'const x = 1;');
|
||||
await writeFile(path.join(tmpDir, 'test.js'), 'const y = 2;');
|
||||
|
||||
const result = await grepContent({
|
||||
cwd: tmpDir,
|
||||
filePattern: '*.ts',
|
||||
pattern: 'const',
|
||||
});
|
||||
|
||||
expect(result).toHaveProperty('success');
|
||||
});
|
||||
|
||||
it('should handle no matches', async () => {
|
||||
await writeFile(path.join(tmpDir, 'empty.txt'), 'nothing here');
|
||||
|
||||
const result = await grepContent({ cwd: tmpDir, pattern: 'xyz_not_found' });
|
||||
|
||||
expect(result.matches).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchLocalFiles', () => {
|
||||
it('should find files by keyword', async () => {
|
||||
await writeFile(path.join(tmpDir, 'config.json'), '{}');
|
||||
await writeFile(path.join(tmpDir, 'config.yaml'), '');
|
||||
await writeFile(path.join(tmpDir, 'readme.md'), '');
|
||||
|
||||
const result = await searchLocalFiles({ directory: tmpDir, keywords: 'config' });
|
||||
|
||||
expect(result.length).toBe(2);
|
||||
expect(result.map((r: any) => r.name)).toContain('config.json');
|
||||
});
|
||||
|
||||
it('should filter by content', async () => {
|
||||
await writeFile(path.join(tmpDir, 'match.txt'), 'this has the secret');
|
||||
await writeFile(path.join(tmpDir, 'nomatch.txt'), 'nothing here');
|
||||
|
||||
// Search with a broad pattern and content filter
|
||||
const result = await searchLocalFiles({
|
||||
contentContains: 'secret',
|
||||
directory: tmpDir,
|
||||
keywords: '',
|
||||
});
|
||||
|
||||
// Content filtering should exclude files without 'secret'
|
||||
expect(result.every((r: any) => r.name !== 'nomatch.txt' || false)).toBe(true);
|
||||
});
|
||||
|
||||
it('should respect limit', async () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await writeFile(path.join(tmpDir, `file${i}.log`), `content ${i}`);
|
||||
}
|
||||
|
||||
const result = await searchLocalFiles({
|
||||
directory: tmpDir,
|
||||
keywords: 'file',
|
||||
limit: 2,
|
||||
});
|
||||
|
||||
expect(result.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should use cwd when directory not specified', async () => {
|
||||
const result = await searchLocalFiles({ keywords: 'nonexistent_xyz_file' });
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
const result = await searchLocalFiles({
|
||||
directory: '/nonexistent/path/xyz',
|
||||
keywords: 'test',
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
expect(result.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
+9
-357
@@ -1,357 +1,9 @@
|
||||
import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { createPatch } from 'diff';
|
||||
import fg from 'fast-glob';
|
||||
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
// ─── readLocalFile ───
|
||||
|
||||
interface ReadFileParams {
|
||||
fullContent?: boolean;
|
||||
loc?: [number, number];
|
||||
path: string;
|
||||
}
|
||||
|
||||
export async function readLocalFile({ path: filePath, loc, fullContent }: ReadFileParams) {
|
||||
const effectiveLoc = fullContent ? undefined : (loc ?? [0, 200]);
|
||||
log.debug(`Reading file: ${filePath}, loc=${JSON.stringify(effectiveLoc)}`);
|
||||
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
const totalLineCount = lines.length;
|
||||
const totalCharCount = content.length;
|
||||
|
||||
let selectedContent: string;
|
||||
let lineCount: number;
|
||||
let actualLoc: [number, number];
|
||||
|
||||
if (effectiveLoc === undefined) {
|
||||
selectedContent = content;
|
||||
lineCount = totalLineCount;
|
||||
actualLoc = [0, totalLineCount];
|
||||
} else {
|
||||
const [startLine, endLine] = effectiveLoc;
|
||||
const selectedLines = lines.slice(startLine, endLine);
|
||||
selectedContent = selectedLines.join('\n');
|
||||
lineCount = selectedLines.length;
|
||||
actualLoc = effectiveLoc;
|
||||
}
|
||||
|
||||
const fileStat = await stat(filePath);
|
||||
|
||||
return {
|
||||
charCount: selectedContent.length,
|
||||
content: selectedContent,
|
||||
createdTime: fileStat.birthtime,
|
||||
fileType: path.extname(filePath).toLowerCase().replace('.', '') || 'unknown',
|
||||
filename: path.basename(filePath),
|
||||
lineCount,
|
||||
loc: actualLoc,
|
||||
modifiedTime: fileStat.mtime,
|
||||
totalCharCount,
|
||||
totalLineCount,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = (error as Error).message;
|
||||
return {
|
||||
charCount: 0,
|
||||
content: `Error accessing or processing file: ${errorMessage}`,
|
||||
createdTime: new Date(),
|
||||
fileType: path.extname(filePath).toLowerCase().replace('.', '') || 'unknown',
|
||||
filename: path.basename(filePath),
|
||||
lineCount: 0,
|
||||
loc: [0, 0] as [number, number],
|
||||
modifiedTime: new Date(),
|
||||
totalCharCount: 0,
|
||||
totalLineCount: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ─── writeLocalFile ───
|
||||
|
||||
interface WriteFileParams {
|
||||
content: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export async function writeLocalFile({ path: filePath, content }: WriteFileParams) {
|
||||
if (!filePath) return { error: 'Path cannot be empty', success: false };
|
||||
if (content === undefined) return { error: 'Content cannot be empty', success: false };
|
||||
|
||||
try {
|
||||
const dirname = path.dirname(filePath);
|
||||
await mkdir(dirname, { recursive: true });
|
||||
await writeFile(filePath, content, 'utf8');
|
||||
log.debug(`File written: ${filePath} (${content.length} chars)`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { error: `Failed to write file: ${(error as Error).message}`, success: false };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── editLocalFile ───
|
||||
|
||||
interface EditFileParams {
|
||||
file_path: string;
|
||||
new_string: string;
|
||||
old_string: string;
|
||||
replace_all?: boolean;
|
||||
}
|
||||
|
||||
export async function editLocalFile({
|
||||
file_path: filePath,
|
||||
old_string,
|
||||
new_string,
|
||||
replace_all = false,
|
||||
}: EditFileParams) {
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
|
||||
if (!content.includes(old_string)) {
|
||||
return {
|
||||
error: 'The specified old_string was not found in the file',
|
||||
replacements: 0,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
let newContent: string;
|
||||
let replacements: number;
|
||||
|
||||
if (replace_all) {
|
||||
const regex = new RegExp(old_string.replaceAll(/[$()*+.?[\\\]^{|}]/g, '\\$&'), 'g');
|
||||
const matches = content.match(regex);
|
||||
replacements = matches ? matches.length : 0;
|
||||
newContent = content.replaceAll(old_string, new_string);
|
||||
} else {
|
||||
const index = content.indexOf(old_string);
|
||||
if (index === -1) {
|
||||
return { error: 'Old string not found', replacements: 0, success: false };
|
||||
}
|
||||
newContent = content.slice(0, index) + new_string + content.slice(index + old_string.length);
|
||||
replacements = 1;
|
||||
}
|
||||
|
||||
await writeFile(filePath, newContent, 'utf8');
|
||||
|
||||
const patch = createPatch(filePath, content, newContent, '', '');
|
||||
const diffText = `diff --git a${filePath} b${filePath}\n${patch}`;
|
||||
|
||||
const patchLines = patch.split('\n');
|
||||
let linesAdded = 0;
|
||||
let linesDeleted = 0;
|
||||
|
||||
for (const line of patchLines) {
|
||||
if (line.startsWith('+') && !line.startsWith('+++')) linesAdded++;
|
||||
else if (line.startsWith('-') && !line.startsWith('---')) linesDeleted++;
|
||||
}
|
||||
|
||||
return { diffText, linesAdded, linesDeleted, replacements, success: true };
|
||||
} catch (error) {
|
||||
return { error: (error as Error).message, replacements: 0, success: false };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── listLocalFiles ───
|
||||
|
||||
interface ListFilesParams {
|
||||
limit?: number;
|
||||
path: string;
|
||||
sortBy?: 'createdTime' | 'modifiedTime' | 'name' | 'size';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export async function listLocalFiles({
|
||||
path: dirPath,
|
||||
sortBy = 'modifiedTime',
|
||||
sortOrder = 'desc',
|
||||
limit = 100,
|
||||
}: ListFilesParams) {
|
||||
try {
|
||||
const entries = await readdir(dirPath);
|
||||
const results: any[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dirPath, entry);
|
||||
try {
|
||||
const stats = await stat(fullPath);
|
||||
const isDirectory = stats.isDirectory();
|
||||
results.push({
|
||||
createdTime: stats.birthtime,
|
||||
isDirectory,
|
||||
lastAccessTime: stats.atime,
|
||||
modifiedTime: stats.mtime,
|
||||
name: entry,
|
||||
path: fullPath,
|
||||
size: stats.size,
|
||||
type: isDirectory ? 'directory' : path.extname(entry).toLowerCase().replace('.', ''),
|
||||
});
|
||||
} catch {
|
||||
// Skip files we can't stat
|
||||
}
|
||||
}
|
||||
|
||||
results.sort((a, b) => {
|
||||
let comparison: number;
|
||||
switch (sortBy) {
|
||||
case 'name': {
|
||||
comparison = (a.name || '').localeCompare(b.name || '');
|
||||
break;
|
||||
}
|
||||
case 'modifiedTime': {
|
||||
comparison = a.modifiedTime.getTime() - b.modifiedTime.getTime();
|
||||
break;
|
||||
}
|
||||
case 'createdTime': {
|
||||
comparison = a.createdTime.getTime() - b.createdTime.getTime();
|
||||
break;
|
||||
}
|
||||
case 'size': {
|
||||
comparison = a.size - b.size;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
comparison = a.modifiedTime.getTime() - b.modifiedTime.getTime();
|
||||
}
|
||||
}
|
||||
return sortOrder === 'desc' ? -comparison : comparison;
|
||||
});
|
||||
|
||||
const totalCount = results.length;
|
||||
return { files: results.slice(0, limit), totalCount };
|
||||
} catch (error) {
|
||||
log.error(`Failed to list directory ${dirPath}:`, error);
|
||||
return { files: [], totalCount: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── globLocalFiles ───
|
||||
|
||||
interface GlobFilesParams {
|
||||
cwd?: string;
|
||||
pattern: string;
|
||||
}
|
||||
|
||||
export async function globLocalFiles({ pattern, cwd }: GlobFilesParams) {
|
||||
try {
|
||||
const files = await fg(pattern, {
|
||||
cwd: cwd || process.cwd(),
|
||||
dot: false,
|
||||
ignore: ['**/node_modules/**', '**/.git/**'],
|
||||
});
|
||||
return { files };
|
||||
} catch (error) {
|
||||
return { error: (error as Error).message, files: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── grepContent ───
|
||||
|
||||
interface GrepContentParams {
|
||||
cwd?: string;
|
||||
filePattern?: string;
|
||||
pattern: string;
|
||||
}
|
||||
|
||||
export async function grepContent({ pattern, cwd, filePattern }: GrepContentParams) {
|
||||
const { spawn } = await import('node:child_process');
|
||||
|
||||
return new Promise<{ matches: any[]; success: boolean }>((resolve) => {
|
||||
const args = ['--json', '-n'];
|
||||
if (filePattern) args.push('--glob', filePattern);
|
||||
args.push(pattern);
|
||||
|
||||
const child = spawn('rg', args, { cwd: cwd || process.cwd() });
|
||||
let stdout = '';
|
||||
|
||||
child.stdout?.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
child.stderr?.on('data', () => {
|
||||
// stderr consumed but not used
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code !== 0 && code !== 1) {
|
||||
// Fallback: use simple regex search
|
||||
log.debug('rg not available, falling back to simple search');
|
||||
resolve({ matches: [], success: false });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const matches = stdout
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
try {
|
||||
return JSON.parse(line);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
resolve({ matches, success: true });
|
||||
} catch {
|
||||
resolve({ matches: [], success: true });
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', () => {
|
||||
log.debug('rg not available');
|
||||
resolve({ matches: [], success: false });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── searchLocalFiles ───
|
||||
|
||||
interface SearchFilesParams {
|
||||
contentContains?: string;
|
||||
directory?: string;
|
||||
keywords: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export async function searchLocalFiles({
|
||||
keywords,
|
||||
directory,
|
||||
contentContains,
|
||||
limit = 30,
|
||||
}: SearchFilesParams) {
|
||||
try {
|
||||
const cwd = directory || process.cwd();
|
||||
const files = await fg(`**/*${keywords}*`, {
|
||||
cwd,
|
||||
dot: false,
|
||||
ignore: ['**/node_modules/**', '**/.git/**'],
|
||||
});
|
||||
|
||||
let results = files.map((f) => ({ name: path.basename(f), path: path.join(cwd, f) }));
|
||||
|
||||
if (contentContains) {
|
||||
const filtered: typeof results = [];
|
||||
for (const file of results) {
|
||||
try {
|
||||
const content = await readFile(file.path, 'utf8');
|
||||
if (content.includes(contentContains)) {
|
||||
filtered.push(file);
|
||||
}
|
||||
} catch {
|
||||
// Skip unreadable files
|
||||
}
|
||||
}
|
||||
results = filtered;
|
||||
}
|
||||
|
||||
return results.slice(0, limit);
|
||||
} catch (error) {
|
||||
log.error('File search failed:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
export {
|
||||
editLocalFile,
|
||||
globLocalFiles,
|
||||
grepContent,
|
||||
listLocalFiles,
|
||||
readLocalFile,
|
||||
searchLocalFiles,
|
||||
writeLocalFile,
|
||||
} from '@lobechat/local-file-shell';
|
||||
|
||||
@@ -11,227 +11,55 @@ vi.mock('../utils/logger', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
describe('shell tools', () => {
|
||||
describe('shell tools (integration wrapper)', () => {
|
||||
afterEach(() => {
|
||||
cleanupAllProcesses();
|
||||
});
|
||||
|
||||
describe('runCommand', () => {
|
||||
it('should execute a simple command', async () => {
|
||||
const result = await runCommand({ command: 'echo hello' });
|
||||
it('should delegate runCommand to shared package', async () => {
|
||||
const result = await runCommand({ command: 'echo hello' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.stdout).toContain('hello');
|
||||
expect(result.exit_code).toBe(0);
|
||||
});
|
||||
|
||||
it('should capture stderr', async () => {
|
||||
const result = await runCommand({ command: 'echo error >&2' });
|
||||
|
||||
expect(result.stderr).toContain('error');
|
||||
});
|
||||
|
||||
it('should handle command failure', async () => {
|
||||
const result = await runCommand({ command: 'exit 1' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.exit_code).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle command not found', async () => {
|
||||
const result = await runCommand({ command: 'nonexistent_command_xyz_123' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should timeout long-running commands', async () => {
|
||||
const result = await runCommand({ command: 'sleep 10', timeout: 500 });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('timed out');
|
||||
}, 10000);
|
||||
|
||||
it('should clamp timeout to minimum 1000ms', async () => {
|
||||
const result = await runCommand({ command: 'echo fast', timeout: 100 });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should run command in background', async () => {
|
||||
const result = await runCommand({
|
||||
command: 'echo background',
|
||||
run_in_background: true,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.shell_id).toBeDefined();
|
||||
});
|
||||
|
||||
it('should strip ANSI codes from output', async () => {
|
||||
const result = await runCommand({
|
||||
command: 'printf "\\033[31mred\\033[0m"',
|
||||
});
|
||||
|
||||
expect(result.output).not.toContain('\u001B');
|
||||
});
|
||||
|
||||
it('should truncate very long output', async () => {
|
||||
// Generate output longer than 80KB
|
||||
const result = await runCommand({
|
||||
command: `python3 -c "print('x' * 100000)" 2>/dev/null || printf '%0.sx' $(seq 1 100000)`,
|
||||
});
|
||||
|
||||
// Output should be truncated
|
||||
expect(result.output.length).toBeLessThanOrEqual(85000); // 80000 + truncation message
|
||||
}, 15000);
|
||||
|
||||
it('should use description in log prefix', async () => {
|
||||
const result = await runCommand({
|
||||
command: 'echo test',
|
||||
description: 'test command',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.stdout).toContain('hello');
|
||||
});
|
||||
|
||||
describe('getCommandOutput', () => {
|
||||
it('should get output from background process', async () => {
|
||||
const bgResult = await runCommand({
|
||||
command: 'echo hello && sleep 0.1',
|
||||
run_in_background: true,
|
||||
});
|
||||
|
||||
// Wait for output to be captured
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
const output = await getCommandOutput({ shell_id: bgResult.shell_id });
|
||||
|
||||
expect(output.success).toBe(true);
|
||||
expect(output.stdout).toContain('hello');
|
||||
it('should delegate background commands and getCommandOutput', async () => {
|
||||
const bgResult = await runCommand({
|
||||
command: 'echo background && sleep 0.1',
|
||||
run_in_background: true,
|
||||
});
|
||||
|
||||
it('should return error for unknown shell_id', async () => {
|
||||
const result = await getCommandOutput({ shell_id: 'unknown-id' });
|
||||
expect(bgResult.success).toBe(true);
|
||||
expect(bgResult.shell_id).toBeDefined();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('not found');
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
it('should track running state', async () => {
|
||||
const bgResult = await runCommand({
|
||||
command: 'sleep 5',
|
||||
run_in_background: true,
|
||||
});
|
||||
|
||||
const output = await getCommandOutput({ shell_id: bgResult.shell_id });
|
||||
|
||||
expect(output.running).toBe(true);
|
||||
});
|
||||
|
||||
it('should support filter parameter', async () => {
|
||||
const bgResult = await runCommand({
|
||||
command: 'echo "line1\nline2\nline3"',
|
||||
run_in_background: true,
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
const output = await getCommandOutput({
|
||||
filter: 'line2',
|
||||
shell_id: bgResult.shell_id,
|
||||
});
|
||||
|
||||
expect(output.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle invalid filter regex', async () => {
|
||||
const bgResult = await runCommand({
|
||||
command: 'echo test',
|
||||
run_in_background: true,
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
const output = await getCommandOutput({
|
||||
filter: '[invalid',
|
||||
shell_id: bgResult.shell_id,
|
||||
});
|
||||
|
||||
expect(output.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should return new output only on subsequent calls', async () => {
|
||||
const bgResult = await runCommand({
|
||||
command: 'echo first && sleep 0.2 && echo second',
|
||||
run_in_background: true,
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
const first = await getCommandOutput({ shell_id: bgResult.shell_id });
|
||||
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
await getCommandOutput({ shell_id: bgResult.shell_id });
|
||||
|
||||
// First read should have "first"
|
||||
expect(first.stdout).toContain('first');
|
||||
});
|
||||
const output = await getCommandOutput({ shell_id: bgResult.shell_id! });
|
||||
expect(output.success).toBe(true);
|
||||
expect(output.stdout).toContain('background');
|
||||
});
|
||||
|
||||
describe('killCommand', () => {
|
||||
it('should kill a background process', async () => {
|
||||
const bgResult = await runCommand({
|
||||
command: 'sleep 60',
|
||||
run_in_background: true,
|
||||
});
|
||||
|
||||
const result = await killCommand({ shell_id: bgResult.shell_id });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
it('should delegate killCommand', async () => {
|
||||
const bgResult = await runCommand({
|
||||
command: 'sleep 60',
|
||||
run_in_background: true,
|
||||
});
|
||||
|
||||
it('should return error for unknown shell_id', async () => {
|
||||
const result = await killCommand({ shell_id: 'unknown-id' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('not found');
|
||||
});
|
||||
const result = await killCommand({ shell_id: bgResult.shell_id! });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
describe('killCommand error handling', () => {
|
||||
it('should handle kill error on already-dead process', async () => {
|
||||
const bgResult = await runCommand({
|
||||
command: 'echo done',
|
||||
run_in_background: true,
|
||||
});
|
||||
|
||||
// Wait for process to finish
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
// Process is already done, killing should still succeed or return error
|
||||
const result = await killCommand({ shell_id: bgResult.shell_id });
|
||||
// It may succeed (process already exited) or fail, but shouldn't throw
|
||||
expect(result).toHaveProperty('success');
|
||||
});
|
||||
it('should return error for unknown shell_id', async () => {
|
||||
const result = await getCommandOutput({ shell_id: 'unknown-id' });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('not found');
|
||||
});
|
||||
|
||||
describe('runCommand error handling', () => {
|
||||
it('should handle spawn error for non-existent shell', async () => {
|
||||
// Test with a command that causes spawn error
|
||||
const result = await runCommand({ command: 'echo test' });
|
||||
// Normal command should work
|
||||
expect(result).toHaveProperty('success');
|
||||
});
|
||||
});
|
||||
it('should cleanup all processes', async () => {
|
||||
await runCommand({ command: 'sleep 60', run_in_background: true });
|
||||
await runCommand({ command: 'sleep 60', run_in_background: true });
|
||||
|
||||
describe('cleanupAllProcesses', () => {
|
||||
it('should kill all background processes', async () => {
|
||||
await runCommand({ command: 'sleep 60', run_in_background: true });
|
||||
await runCommand({ command: 'sleep 60', run_in_background: true });
|
||||
|
||||
cleanupAllProcesses();
|
||||
|
||||
// No processes should remain - subsequent getCommandOutput should fail
|
||||
});
|
||||
cleanupAllProcesses();
|
||||
// No assertion needed — verifies no throw
|
||||
});
|
||||
});
|
||||
|
||||
+15
-221
@@ -1,233 +1,27 @@
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
type GetCommandOutputParams,
|
||||
type KillCommandParams,
|
||||
runCommand as runCommandCore,
|
||||
type RunCommandParams,
|
||||
ShellProcessManager,
|
||||
} from '@lobechat/local-file-shell';
|
||||
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
// Maximum output length to prevent context explosion
|
||||
const MAX_OUTPUT_LENGTH = 80_000;
|
||||
|
||||
const ANSI_REGEX =
|
||||
// eslint-disable-next-line no-control-regex
|
||||
/\u001B(?:[\u0040-\u005A\u005C-\u005F]|\[[\u0030-\u003F]*[\u0020-\u002F]*[\u0040-\u007E])/g;
|
||||
const stripAnsi = (str: string): string => str.replaceAll(ANSI_REGEX, '');
|
||||
|
||||
const truncateOutput = (str: string, maxLength: number = MAX_OUTPUT_LENGTH): string => {
|
||||
const cleaned = stripAnsi(str);
|
||||
if (cleaned.length <= maxLength) return cleaned;
|
||||
return (
|
||||
cleaned.slice(0, maxLength) +
|
||||
'\n... [truncated, ' +
|
||||
(cleaned.length - maxLength) +
|
||||
' more characters]'
|
||||
);
|
||||
};
|
||||
|
||||
interface ShellProcess {
|
||||
lastReadStderr: number;
|
||||
lastReadStdout: number;
|
||||
process: ChildProcess;
|
||||
stderr: string[];
|
||||
stdout: string[];
|
||||
}
|
||||
|
||||
const shellProcesses = new Map<string, ShellProcess>();
|
||||
const processManager = new ShellProcessManager();
|
||||
|
||||
export function cleanupAllProcesses() {
|
||||
for (const [id, sp] of shellProcesses) {
|
||||
try {
|
||||
sp.process.kill();
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
shellProcesses.delete(id);
|
||||
}
|
||||
processManager.cleanupAll();
|
||||
}
|
||||
|
||||
// ─── runCommand ───
|
||||
|
||||
interface RunCommandParams {
|
||||
command: string;
|
||||
description?: string;
|
||||
run_in_background?: boolean;
|
||||
timeout?: number;
|
||||
export async function runCommand(params: RunCommandParams) {
|
||||
return runCommandCore(params, { logger: log, processManager });
|
||||
}
|
||||
|
||||
export async function runCommand({
|
||||
command,
|
||||
description,
|
||||
run_in_background,
|
||||
timeout = 120_000,
|
||||
}: RunCommandParams) {
|
||||
const logPrefix = `[runCommand: ${description || command.slice(0, 50)}]`;
|
||||
log.debug(`${logPrefix} Starting`, { background: run_in_background, timeout });
|
||||
|
||||
const effectiveTimeout = Math.min(Math.max(timeout, 1000), 600_000);
|
||||
|
||||
const shellConfig =
|
||||
process.platform === 'win32'
|
||||
? { args: ['/c', command], cmd: 'cmd.exe' }
|
||||
: { args: ['-c', command], cmd: '/bin/sh' };
|
||||
|
||||
try {
|
||||
if (run_in_background) {
|
||||
const shellId = randomUUID();
|
||||
const childProcess = spawn(shellConfig.cmd, shellConfig.args, {
|
||||
env: process.env,
|
||||
shell: false,
|
||||
});
|
||||
|
||||
const shellProcess: ShellProcess = {
|
||||
lastReadStderr: 0,
|
||||
lastReadStdout: 0,
|
||||
process: childProcess,
|
||||
stderr: [],
|
||||
stdout: [],
|
||||
};
|
||||
|
||||
childProcess.stdout?.on('data', (data) => {
|
||||
shellProcess.stdout.push(data.toString());
|
||||
});
|
||||
|
||||
childProcess.stderr?.on('data', (data) => {
|
||||
shellProcess.stderr.push(data.toString());
|
||||
});
|
||||
|
||||
childProcess.on('exit', (code) => {
|
||||
log.debug(`${logPrefix} Background process exited`, { code, shellId });
|
||||
});
|
||||
|
||||
shellProcesses.set(shellId, shellProcess);
|
||||
|
||||
log.debug(`${logPrefix} Started background`, { shellId });
|
||||
return { shell_id: shellId, success: true };
|
||||
} else {
|
||||
return new Promise<any>((resolve) => {
|
||||
const childProcess = spawn(shellConfig.cmd, shellConfig.args, {
|
||||
env: process.env,
|
||||
shell: false,
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let killed = false;
|
||||
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
killed = true;
|
||||
childProcess.kill();
|
||||
resolve({
|
||||
error: `Command timed out after ${effectiveTimeout}ms`,
|
||||
stderr: truncateOutput(stderr),
|
||||
stdout: truncateOutput(stdout),
|
||||
success: false,
|
||||
});
|
||||
}, effectiveTimeout);
|
||||
|
||||
childProcess.stdout?.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
childProcess.stderr?.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
childProcess.on('exit', (code) => {
|
||||
if (!killed) {
|
||||
clearTimeout(timeoutHandle);
|
||||
const success = code === 0;
|
||||
resolve({
|
||||
exit_code: code || 0,
|
||||
output: truncateOutput(stdout + stderr),
|
||||
stderr: truncateOutput(stderr),
|
||||
stdout: truncateOutput(stdout),
|
||||
success,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
childProcess.on('error', (error) => {
|
||||
clearTimeout(timeoutHandle);
|
||||
resolve({
|
||||
error: error.message,
|
||||
stderr: truncateOutput(stderr),
|
||||
stdout: truncateOutput(stdout),
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return { error: (error as Error).message, success: false };
|
||||
}
|
||||
export async function getCommandOutput(params: GetCommandOutputParams) {
|
||||
return processManager.getOutput(params);
|
||||
}
|
||||
|
||||
// ─── getCommandOutput ───
|
||||
|
||||
interface GetCommandOutputParams {
|
||||
filter?: string;
|
||||
shell_id: string;
|
||||
}
|
||||
|
||||
export async function getCommandOutput({ shell_id, filter }: GetCommandOutputParams) {
|
||||
const shellProcess = shellProcesses.get(shell_id);
|
||||
if (!shellProcess) {
|
||||
return {
|
||||
error: `Shell ID ${shell_id} not found`,
|
||||
output: '',
|
||||
running: false,
|
||||
stderr: '',
|
||||
stdout: '',
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
const { lastReadStderr, lastReadStdout, process: childProcess, stderr, stdout } = shellProcess;
|
||||
|
||||
const newStdout = stdout.slice(lastReadStdout).join('');
|
||||
const newStderr = stderr.slice(lastReadStderr).join('');
|
||||
let output = newStdout + newStderr;
|
||||
|
||||
if (filter) {
|
||||
try {
|
||||
const regex = new RegExp(filter, 'gm');
|
||||
const lines = output.split('\n');
|
||||
output = lines.filter((line) => regex.test(line)).join('\n');
|
||||
} catch {
|
||||
// Invalid filter regex, use unfiltered output
|
||||
}
|
||||
}
|
||||
|
||||
shellProcess.lastReadStdout = stdout.length;
|
||||
shellProcess.lastReadStderr = stderr.length;
|
||||
|
||||
const running = childProcess.exitCode === null;
|
||||
|
||||
return {
|
||||
output: truncateOutput(output),
|
||||
running,
|
||||
stderr: truncateOutput(newStderr),
|
||||
stdout: truncateOutput(newStdout),
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── killCommand ───
|
||||
|
||||
interface KillCommandParams {
|
||||
shell_id: string;
|
||||
}
|
||||
|
||||
export async function killCommand({ shell_id }: KillCommandParams) {
|
||||
const shellProcess = shellProcesses.get(shell_id);
|
||||
if (!shellProcess) {
|
||||
return { error: `Shell ID ${shell_id} not found`, success: false };
|
||||
}
|
||||
|
||||
try {
|
||||
shellProcess.process.kill();
|
||||
shellProcesses.delete(shell_id);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { error: (error as Error).message, success: false };
|
||||
}
|
||||
export async function killCommand(params: KillCommandParams) {
|
||||
return processManager.kill(params.shell_id);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`printBoxTable > should render a basic table 1`] = `
|
||||
"┌───────┬───────┐
|
||||
│ Name │ Count │
|
||||
├───────┼───────┤
|
||||
│ Alice │ 100 │
|
||||
├───────┼───────┤
|
||||
│ Bob │ 2,345 │
|
||||
└───────┴───────┘"
|
||||
`;
|
||||
|
||||
exports[`printBoxTable > should render a table with title and multi-line cells 1`] = `
|
||||
"
|
||||
╭─────────────────────────────────────────────────╮
|
||||
│ Test Report │
|
||||
╰─────────────────────────────────────────────────╯
|
||||
|
||||
┌────────────┬───────────────────┬────────┬───────┐
|
||||
│ Date │ Models │ Total │ Cost │
|
||||
│ │ │ Tokens │ (USD) │
|
||||
├────────────┼───────────────────┼────────┼───────┤
|
||||
│ 2026-03-01 │ - claude-opus-4-6 │ 19,134 │ $1.23 │
|
||||
│ │ - gpt-4o │ │ │
|
||||
├────────────┼───────────────────┼────────┼───────┤
|
||||
│ 2026-03-02 │ - claude-opus-4-6 │ 5,678 │ $0.45 │
|
||||
└────────────┴───────────────────┴────────┴───────┘"
|
||||
`;
|
||||
|
||||
exports[`printBoxTable > should render the usage table format 1`] = `
|
||||
"
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ LobeHub Token Usage Report - Monthly (2026-03) │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
┌────────────┬────────────────────────┬───────────┬─────────┬───────────┬──────────┬───────┐
|
||||
│ Date │ Models │ Input │ Output │ Total │ Requests │ Cost │
|
||||
│ │ │ │ │ Tokens │ │ (USD) │
|
||||
├────────────┼────────────────────────┼───────────┼─────────┼───────────┼──────────┼───────┤
|
||||
│ 2026-03-01 │ - claude-opus-4-6 │ 4,190,339 │ 121,035 │ 4,311,374 │ 69 │ $3.56 │
|
||||
│ │ - gemini-3-pro-preview │ │ │ │ │ │
|
||||
├────────────┼────────────────────────┼───────────┼─────────┼───────────┼──────────┼───────┤
|
||||
│ 2026-03-02 │ - claude-opus-4-6 │ 4,575,189 │ 34,885 │ 4,610,074 │ 62 │ $4.75 │
|
||||
├────────────┼────────────────────────┼───────────┼─────────┼───────────┼──────────┼───────┤
|
||||
│ Total │ │ 8,765,528 │ 155,920 │ 8,921,448 │ 131 │ $8.31 │
|
||||
└────────────┴────────────────────────┴───────────┴─────────┴───────────┴──────────┴───────┘"
|
||||
`;
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { formatCost, formatNumber, printBoxTable } from './format';
|
||||
|
||||
describe('formatNumber', () => {
|
||||
it('should format numbers with commas', () => {
|
||||
expect(formatNumber(0)).toBe('0');
|
||||
expect(formatNumber(1234)).toBe('1,234');
|
||||
expect(formatNumber(1_234_567)).toBe('1,234,567');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatCost', () => {
|
||||
it('should format cost with dollar sign', () => {
|
||||
expect(formatCost(0)).toBe('$0.00');
|
||||
expect(formatCost(1.5)).toBe('$1.50');
|
||||
expect(formatCost(123.456)).toBe('$123.46');
|
||||
});
|
||||
});
|
||||
|
||||
describe('printBoxTable', () => {
|
||||
it('should render a basic table', () => {
|
||||
const output: string[] = [];
|
||||
vi.spyOn(console, 'log').mockImplementation((...args: any[]) => {
|
||||
output.push(args.join(' '));
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{ align: 'left' as const, header: 'Name', key: 'name' },
|
||||
{ align: 'right' as const, header: 'Count', key: 'count' },
|
||||
];
|
||||
|
||||
const rows = [
|
||||
{ count: '100', name: 'Alice' },
|
||||
{ count: '2,345', name: 'Bob' },
|
||||
];
|
||||
|
||||
printBoxTable(columns, rows);
|
||||
expect(output.join('\n')).toMatchSnapshot();
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should render a table with title and multi-line cells', () => {
|
||||
const output: string[] = [];
|
||||
vi.spyOn(console, 'log').mockImplementation((...args: any[]) => {
|
||||
output.push(args.join(' '));
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{ align: 'left' as const, header: 'Date', key: 'date' },
|
||||
{ align: 'left' as const, header: 'Models', key: 'models' },
|
||||
{ align: 'right' as const, header: ['Total', 'Tokens'], key: 'total' },
|
||||
{ align: 'right' as const, header: ['Cost', '(USD)'], key: 'cost' },
|
||||
];
|
||||
|
||||
const rows = [
|
||||
{
|
||||
cost: '$1.23',
|
||||
date: '2026-03-01',
|
||||
models: ['- claude-opus-4-6', '- gpt-4o'],
|
||||
total: '19,134',
|
||||
},
|
||||
{
|
||||
cost: '$0.45',
|
||||
date: '2026-03-02',
|
||||
models: ['- claude-opus-4-6'],
|
||||
total: '5,678',
|
||||
},
|
||||
];
|
||||
|
||||
printBoxTable(columns, rows, 'Test Report');
|
||||
expect(output.join('\n')).toMatchSnapshot();
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should render the usage table format', () => {
|
||||
const output: string[] = [];
|
||||
vi.spyOn(console, 'log').mockImplementation((...args: any[]) => {
|
||||
output.push(args.join(' '));
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{ align: 'left' as const, header: 'Date', key: 'date' },
|
||||
{ align: 'left' as const, header: 'Models', key: 'models' },
|
||||
{ align: 'right' as const, header: 'Input', key: 'input' },
|
||||
{ align: 'right' as const, header: 'Output', key: 'output' },
|
||||
{ align: 'right' as const, header: ['Total', 'Tokens'], key: 'total' },
|
||||
{ align: 'right' as const, header: 'Requests', key: 'requests' },
|
||||
{ align: 'right' as const, header: ['Cost', '(USD)'], key: 'cost' },
|
||||
];
|
||||
|
||||
const rows = [
|
||||
{
|
||||
cost: '$3.56',
|
||||
date: '2026-03-01',
|
||||
input: '4,190,339',
|
||||
models: ['- claude-opus-4-6', '- gemini-3-pro-preview'],
|
||||
output: '121,035',
|
||||
requests: '69',
|
||||
total: '4,311,374',
|
||||
},
|
||||
{
|
||||
cost: '$4.75',
|
||||
date: '2026-03-02',
|
||||
input: '4,575,189',
|
||||
models: ['- claude-opus-4-6'],
|
||||
output: '34,885',
|
||||
requests: '62',
|
||||
total: '4,610,074',
|
||||
},
|
||||
{
|
||||
cost: '$8.31',
|
||||
date: 'Total',
|
||||
input: '8,765,528',
|
||||
models: '',
|
||||
output: '155,920',
|
||||
requests: '131',
|
||||
total: '8,921,448',
|
||||
},
|
||||
];
|
||||
|
||||
printBoxTable(columns, rows, 'LobeHub Token Usage Report - Monthly (2026-03)');
|
||||
expect(output.join('\n')).toMatchSnapshot();
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
@@ -15,24 +15,222 @@ export function timeAgo(date: Date | string): string {
|
||||
return `${seconds}s ago`;
|
||||
}
|
||||
|
||||
export function truncate(str: string, len: number): string {
|
||||
if (str.length <= len) return str;
|
||||
return str.slice(0, len - 1) + '…';
|
||||
export function truncate(str: string, maxWidth: number): string {
|
||||
let width = 0;
|
||||
let i = 0;
|
||||
for (const char of str) {
|
||||
const code = char.codePointAt(0)!;
|
||||
const cw =
|
||||
(code >= 0x1100 && code <= 0x115f) ||
|
||||
(code >= 0x2e80 && code <= 0x303e) ||
|
||||
(code >= 0x3040 && code <= 0x33bf) ||
|
||||
(code >= 0x3400 && code <= 0x4dbf) ||
|
||||
(code >= 0x4e00 && code <= 0x9fff) ||
|
||||
(code >= 0xa000 && code <= 0xa4cf) ||
|
||||
(code >= 0xac00 && code <= 0xd7af) ||
|
||||
(code >= 0xf900 && code <= 0xfaff) ||
|
||||
(code >= 0xfe30 && code <= 0xfe6f) ||
|
||||
(code >= 0xff01 && code <= 0xff60) ||
|
||||
(code >= 0xffe0 && code <= 0xffe6) ||
|
||||
(code >= 0x20000 && code <= 0x2fa1f)
|
||||
? 2
|
||||
: 1;
|
||||
if (width + cw > maxWidth - 1) {
|
||||
return str.slice(0, i) + '…';
|
||||
}
|
||||
width += cw;
|
||||
i += char.length;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
export function printTable(rows: string[][], header: string[]) {
|
||||
const allRows = [header, ...rows];
|
||||
const colWidths = header.map((_, i) => Math.max(...allRows.map((r) => (r[i] || '').length)));
|
||||
const colWidths = header.map((_, i) => Math.max(...allRows.map((r) => displayWidth(r[i] || ''))));
|
||||
|
||||
const headerLine = header.map((h, i) => h.padEnd(colWidths[i])).join(' ');
|
||||
const headerLine = header.map((h, i) => padDisplay(h, colWidths[i])).join(' ');
|
||||
console.log(pc.bold(headerLine));
|
||||
|
||||
for (const row of rows) {
|
||||
const line = row.map((cell, i) => (cell || '').padEnd(colWidths[i])).join(' ');
|
||||
const line = row.map((cell, i) => padDisplay(cell || '', colWidths[i])).join(' ');
|
||||
console.log(line);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Box-drawing table ─────────────────────────────────────
|
||||
|
||||
interface BoxTableColumn {
|
||||
align?: 'left' | 'right';
|
||||
header: string | string[];
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface BoxTableRow {
|
||||
[key: string]: string | string[];
|
||||
}
|
||||
|
||||
export function formatNumber(n: number): string {
|
||||
return n.toLocaleString('en-US');
|
||||
}
|
||||
|
||||
export function formatCost(n: number): string {
|
||||
return `$${n.toFixed(2)}`;
|
||||
}
|
||||
|
||||
// Strip ANSI escape codes for accurate width calculation
|
||||
function stripAnsi(s: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return s.replaceAll(/\x1B\[[0-9;]*m/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the display width of a string in the terminal.
|
||||
* CJK characters and fullwidth symbols occupy 2 columns.
|
||||
*/
|
||||
function displayWidth(s: string): number {
|
||||
const plain = stripAnsi(s);
|
||||
let width = 0;
|
||||
for (const char of plain) {
|
||||
const code = char.codePointAt(0)!;
|
||||
if (
|
||||
(code >= 0x1100 && code <= 0x115f) || // Hangul Jamo
|
||||
(code >= 0x2e80 && code <= 0x303e) || // CJK Radicals, Kangxi, Symbols
|
||||
(code >= 0x3040 && code <= 0x33bf) || // Hiragana, Katakana, Bopomofo, CJK Compat
|
||||
(code >= 0x3400 && code <= 0x4dbf) || // CJK Extension A
|
||||
(code >= 0x4e00 && code <= 0x9fff) || // CJK Unified Ideographs
|
||||
(code >= 0xa000 && code <= 0xa4cf) || // Yi Syllables/Radicals
|
||||
(code >= 0xac00 && code <= 0xd7af) || // Hangul Syllables
|
||||
(code >= 0xf900 && code <= 0xfaff) || // CJK Compatibility Ideographs
|
||||
(code >= 0xfe30 && code <= 0xfe6f) || // CJK Compatibility Forms
|
||||
(code >= 0xff01 && code <= 0xff60) || // Fullwidth Forms
|
||||
(code >= 0xffe0 && code <= 0xffe6) || // Fullwidth Signs
|
||||
(code >= 0x20000 && code <= 0x2fa1f) // CJK Extension B–F, Compatibility Supplement
|
||||
) {
|
||||
width += 2;
|
||||
} else {
|
||||
width += 1;
|
||||
}
|
||||
}
|
||||
return width;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pad a string to the target display width, accounting for CJK double-width characters.
|
||||
*/
|
||||
function padDisplay(s: string, targetWidth: number, align: 'left' | 'right' = 'left'): string {
|
||||
const gap = targetWidth - displayWidth(s);
|
||||
if (gap <= 0) return s;
|
||||
return align === 'right' ? ' '.repeat(gap) + s : s + ' '.repeat(gap);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a bordered table with box-drawing characters, similar to ccusage output.
|
||||
* Supports multi-line cells (string[]).
|
||||
*/
|
||||
export function printBoxTable(columns: BoxTableColumn[], rows: BoxTableRow[], title?: string) {
|
||||
// Calculate the display height of each row (max lines across all cells)
|
||||
const rowHeights = rows.map((row) => {
|
||||
let maxLines = 1;
|
||||
for (const col of columns) {
|
||||
const val = row[col.key];
|
||||
if (Array.isArray(val) && val.length > maxLines) maxLines = val.length;
|
||||
}
|
||||
return maxLines;
|
||||
});
|
||||
|
||||
// Calculate column widths: max of header width and all cell widths
|
||||
const colWidths = columns.map((col) => {
|
||||
const headerLines = Array.isArray(col.header) ? col.header : [col.header];
|
||||
let maxW = Math.max(...headerLines.map((h) => displayWidth(h)));
|
||||
for (const row of rows) {
|
||||
const val = row[col.key];
|
||||
const lines = Array.isArray(val) ? val : [val || ''];
|
||||
for (const line of lines) {
|
||||
const w = displayWidth(line);
|
||||
if (w > maxW) maxW = w;
|
||||
}
|
||||
}
|
||||
return maxW;
|
||||
});
|
||||
|
||||
// Box-drawing chars
|
||||
const TL = '┌',
|
||||
TR = '┐',
|
||||
BL = '└',
|
||||
BR = '┘';
|
||||
const H = '─',
|
||||
V = '│';
|
||||
const TJ = '┬',
|
||||
BJ = '┴',
|
||||
LJ = '├',
|
||||
RJ = '┤',
|
||||
CJ = '┼';
|
||||
|
||||
const pad = (s: string, w: number, align: 'left' | 'right' = 'left') => {
|
||||
return padDisplay(s, w, align);
|
||||
};
|
||||
|
||||
const hLine = (left: string, mid: string, right: string) =>
|
||||
left + colWidths.map((w) => H.repeat(w + 2)).join(mid) + right;
|
||||
|
||||
const renderRow = (cells: string[], align?: ('left' | 'right')[]) =>
|
||||
V +
|
||||
cells.map((c, i) => ' ' + pad(c, colWidths[i], align?.[i] || columns[i].align) + ' ').join(V) +
|
||||
V;
|
||||
|
||||
// Title box
|
||||
if (title) {
|
||||
const totalWidth = colWidths.reduce((a, b) => a + b, 0) + (colWidths.length - 1) * 3 + 4;
|
||||
const innerW = totalWidth - 4;
|
||||
const titlePad = Math.max(0, innerW - displayWidth(title));
|
||||
const leftPad = Math.floor(titlePad / 2);
|
||||
const rightPad = titlePad - leftPad;
|
||||
console.log();
|
||||
console.log(' ╭' + '─'.repeat(innerW + 2) + '╮');
|
||||
console.log(' │ ' + ' '.repeat(leftPad) + pc.bold(title) + ' '.repeat(rightPad) + ' │');
|
||||
console.log(' ╰' + '─'.repeat(innerW + 2) + '╯');
|
||||
console.log();
|
||||
}
|
||||
|
||||
// Header
|
||||
const headerHeight = Math.max(
|
||||
...columns.map((c) => (Array.isArray(c.header) ? c.header.length : 1)),
|
||||
);
|
||||
|
||||
console.log(hLine(TL, TJ, TR));
|
||||
for (let line = 0; line < headerHeight; line++) {
|
||||
const cells = columns.map((col) => {
|
||||
const headerLines = Array.isArray(col.header) ? col.header : [col.header];
|
||||
return headerLines[line] || '';
|
||||
});
|
||||
console.log(
|
||||
renderRow(
|
||||
cells,
|
||||
columns.map(() => 'left'),
|
||||
),
|
||||
);
|
||||
}
|
||||
console.log(hLine(LJ, CJ, RJ));
|
||||
|
||||
// Data rows
|
||||
rows.forEach((row, rowIdx) => {
|
||||
const height = rowHeights[rowIdx];
|
||||
for (let line = 0; line < height; line++) {
|
||||
const cells = columns.map((col) => {
|
||||
const val = row[col.key];
|
||||
const lines = Array.isArray(val) ? val : [val || ''];
|
||||
return lines[line] || '';
|
||||
});
|
||||
console.log(renderRow(cells));
|
||||
}
|
||||
if (rowIdx < rows.length - 1) {
|
||||
console.log(hLine(LJ, CJ, RJ));
|
||||
}
|
||||
});
|
||||
|
||||
console.log(hLine(BL, BJ, BR));
|
||||
}
|
||||
|
||||
export function pickFields(obj: Record<string, any>, fields: string[]): Record<string, any> {
|
||||
const result: Record<string, any> = {};
|
||||
for (const f of fields) {
|
||||
@@ -60,6 +258,135 @@ export function outputJson(data: unknown, fields?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Calendar Heatmap ──────────────────────────────────────
|
||||
|
||||
interface CalendarDay {
|
||||
day: string; // YYYY-MM-DD
|
||||
value: number;
|
||||
}
|
||||
|
||||
const HEATMAP_BLOCKS = [' ', '░', '▒', '▓', '█'];
|
||||
const WEEKDAY_LABELS = ['Mon', '', 'Wed', '', 'Fri', '', ''];
|
||||
|
||||
/**
|
||||
* Render a GitHub-style calendar heatmap for usage data.
|
||||
* Each column is a week, rows are weekdays (Mon-Sun).
|
||||
*/
|
||||
export function printCalendarHeatmap(
|
||||
data: CalendarDay[],
|
||||
options?: { label?: string; title?: string },
|
||||
) {
|
||||
if (data.length === 0) return;
|
||||
|
||||
// Build a value map
|
||||
const valueMap = new Map<string, number>();
|
||||
let maxVal = 0;
|
||||
for (const d of data) {
|
||||
valueMap.set(d.day, d.value);
|
||||
if (d.value > maxVal) maxVal = d.value;
|
||||
}
|
||||
|
||||
// Determine date range - pad to full weeks
|
||||
const sorted = [...data].sort((a, b) => a.day.localeCompare(b.day));
|
||||
const firstDate = new Date(sorted[0].day);
|
||||
const lastDate = new Date(sorted.at(-1).day);
|
||||
|
||||
// Adjust to start on Monday
|
||||
const startDay = firstDate.getDay(); // 0=Sun, 1=Mon, ...
|
||||
const mondayOffset = startDay === 0 ? 6 : startDay - 1;
|
||||
const start = new Date(firstDate);
|
||||
start.setDate(start.getDate() - mondayOffset);
|
||||
|
||||
// Adjust to end on Sunday
|
||||
const endDay = lastDate.getDay();
|
||||
const sundayOffset = endDay === 0 ? 0 : 7 - endDay;
|
||||
const end = new Date(lastDate);
|
||||
end.setDate(end.getDate() + sundayOffset);
|
||||
|
||||
// Build grid: 7 rows (Mon-Sun) x N weeks
|
||||
const weeks: string[][] = [];
|
||||
const current = new Date(start);
|
||||
let weekCol: string[] = [];
|
||||
|
||||
while (current <= end) {
|
||||
const key = current.toISOString().slice(0, 10);
|
||||
const val = valueMap.get(key) || 0;
|
||||
|
||||
// Quantize to block level
|
||||
let level: number;
|
||||
if (val === 0) {
|
||||
level = 0;
|
||||
} else if (maxVal > 0) {
|
||||
level = Math.ceil((val / maxVal) * 4);
|
||||
if (level < 1) level = 1;
|
||||
if (level > 4) level = 4;
|
||||
} else {
|
||||
level = 0;
|
||||
}
|
||||
|
||||
// Color the block
|
||||
const block = HEATMAP_BLOCKS[level];
|
||||
const colored = level > 0 ? pc.green(block) : pc.dim(block);
|
||||
|
||||
weekCol.push(colored);
|
||||
|
||||
if (weekCol.length === 7) {
|
||||
weeks.push(weekCol);
|
||||
weekCol = [];
|
||||
}
|
||||
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
if (weekCol.length > 0) {
|
||||
while (weekCol.length < 7) weekCol.push(' ');
|
||||
weeks.push(weekCol);
|
||||
}
|
||||
|
||||
// Print title
|
||||
if (options?.title) {
|
||||
console.log();
|
||||
console.log(pc.bold(options.title));
|
||||
}
|
||||
|
||||
// Print month labels on top, aligned with week columns
|
||||
const monthLine: string[] = [];
|
||||
let lastMonth = '';
|
||||
for (let w = 0; w < weeks.length; w++) {
|
||||
const weekStart = new Date(start);
|
||||
weekStart.setDate(weekStart.getDate() + w * 7);
|
||||
const monthStr = weekStart.toLocaleString('en-US', { month: 'short' });
|
||||
if (monthStr !== lastMonth) {
|
||||
monthLine.push(monthStr.padEnd(2));
|
||||
lastMonth = monthStr;
|
||||
} else {
|
||||
monthLine.push(' ');
|
||||
}
|
||||
}
|
||||
console.log(pc.dim(' ' + monthLine.join('')));
|
||||
|
||||
// Print each row (weekday)
|
||||
for (let row = 0; row < 7; row++) {
|
||||
const label = (WEEKDAY_LABELS[row] || '').padEnd(4);
|
||||
const cells = weeks.map((week) => week[row] || ' ').join(' ');
|
||||
console.log(pc.dim(label) + ' ' + cells);
|
||||
}
|
||||
|
||||
// Legend
|
||||
const legend =
|
||||
' ' +
|
||||
pc.dim('Less ') +
|
||||
HEATMAP_BLOCKS.map((b, i) => (i === 0 ? pc.dim(b) : pc.green(b))).join(' ') +
|
||||
pc.dim(' More');
|
||||
console.log();
|
||||
console.log(legend);
|
||||
|
||||
// Label
|
||||
if (options?.label) {
|
||||
console.log(pc.dim(` ${options.label}`));
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
export function confirm(message: string): Promise<boolean> {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
||||
return new Promise((resolve) => {
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"isolatedModules": true,
|
||||
"paths": {
|
||||
"@lobechat/device-gateway-client": ["../../packages/device-gateway-client/src"],
|
||||
"@lobechat/local-file-shell": ["../../packages/local-file-shell/src"],
|
||||
"@/*": ["../../src/*"]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -4,8 +4,15 @@ export default defineConfig({
|
||||
banner: { js: '#!/usr/bin/env node' },
|
||||
clean: true,
|
||||
entry: ['src/index.ts'],
|
||||
external: ['@napi-rs/canvas', 'fast-glob', 'diff', 'debug'],
|
||||
format: ['esm'],
|
||||
noExternal: ['@lobechat/device-gateway-client', '@trpc/client', 'superjson'],
|
||||
noExternal: [
|
||||
'@lobechat/device-gateway-client',
|
||||
'@lobechat/local-file-shell',
|
||||
'@lobechat/file-loaders',
|
||||
'@trpc/client',
|
||||
'superjson',
|
||||
],
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
});
|
||||
|
||||
@@ -9,6 +9,14 @@ export default defineConfig({
|
||||
find: '@lobechat/device-gateway-client',
|
||||
replacement: path.resolve(__dirname, '../../packages/device-gateway-client/src/index.ts'),
|
||||
},
|
||||
{
|
||||
find: '@lobechat/local-file-shell',
|
||||
replacement: path.resolve(__dirname, '../../packages/local-file-shell/src/index.ts'),
|
||||
},
|
||||
{
|
||||
find: '@lobechat/file-loaders',
|
||||
replacement: path.resolve(__dirname, '../../packages/file-loaders/src/index.ts'),
|
||||
},
|
||||
],
|
||||
},
|
||||
test: {
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
"@lobechat/electron-client-ipc": "workspace:*",
|
||||
"@lobechat/electron-server-ipc": "workspace:*",
|
||||
"@lobechat/file-loaders": "workspace:*",
|
||||
"@lobechat/local-file-shell": "workspace:*",
|
||||
"@lobehub/i18n-cli": "^1.25.1",
|
||||
"@modelcontextprotocol/sdk": "^1.24.3",
|
||||
"@t3-oss/env-core": "^0.13.8",
|
||||
|
||||
@@ -3,4 +3,5 @@ packages:
|
||||
- '../../packages/electron-client-ipc'
|
||||
- '../../packages/file-loaders'
|
||||
- '../../packages/desktop-bridge'
|
||||
- '../../packages/local-file-shell'
|
||||
- '.'
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"common.checkUpdates": "التحقق من التحديثات...",
|
||||
"common.checkingUpdates": "جاري التحقق من التحديثات...",
|
||||
"common.downloadingUpdate": "جاري تنزيل التحديث...",
|
||||
"common.isLatestVersion": "الإصدار محدث بالفعل",
|
||||
"common.restartToUpdate": "أعد التشغيل للتحديث",
|
||||
"context.copyImage": "نسخ الصورة",
|
||||
"context.copyImageAddress": "نسخ عنوان الصورة",
|
||||
"context.copyLink": "نسخ الرابط",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"common.checkUpdates": "Проверка за актуализации...",
|
||||
"common.checkingUpdates": "Проверяване за актуализации...",
|
||||
"common.downloadingUpdate": "Изтегляне на актуализация...",
|
||||
"common.isLatestVersion": "Вече сте с последната версия",
|
||||
"common.restartToUpdate": "Рестартирайте за актуализация",
|
||||
"context.copyImage": "Копирай изображение",
|
||||
"context.copyImageAddress": "Копирай адреса на изображението",
|
||||
"context.copyLink": "Копирай връзката",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"common.checkUpdates": "Überprüfen Sie auf Updates...",
|
||||
"common.checkingUpdates": "Updates werden überprüft...",
|
||||
"common.downloadingUpdate": "Update wird heruntergeladen...",
|
||||
"common.isLatestVersion": "Bereits aktuell",
|
||||
"common.restartToUpdate": "Neu starten zum Aktualisieren",
|
||||
"context.copyImage": "Bild kopieren",
|
||||
"context.copyImageAddress": "Bildadresse kopieren",
|
||||
"context.copyLink": "Link kopieren",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"common.checkUpdates": "Check for updates...",
|
||||
"common.checkingUpdates": "Checking for updates...",
|
||||
"common.downloadingUpdate": "Downloading update...",
|
||||
"common.isLatestVersion": "Already up to date",
|
||||
"common.restartToUpdate": "Restart to update",
|
||||
"context.copyImage": "Copy Image",
|
||||
"context.copyImageAddress": "Copy Image Address",
|
||||
"context.copyLink": "Copy Link",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"common.checkUpdates": "Comprobando actualizaciones...",
|
||||
"common.checkUpdates": "Comprobar actualizaciones...",
|
||||
"common.checkingUpdates": "Comprobando actualizaciones...",
|
||||
"common.downloadingUpdate": "Descargando actualización...",
|
||||
"common.isLatestVersion": "Ya está actualizado",
|
||||
"common.restartToUpdate": "Reiniciar para actualizar",
|
||||
"context.copyImage": "Copiar imagen",
|
||||
"context.copyImageAddress": "Copiar dirección de la imagen",
|
||||
"context.copyLink": "Copiar enlace",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"common.checkUpdates": "بررسی بهروزرسانی...",
|
||||
"common.checkingUpdates": "در حال بررسی بهروزرسانی...",
|
||||
"common.downloadingUpdate": "در حال دانلود بهروزرسانی...",
|
||||
"common.isLatestVersion": "قبلاً بهروز است",
|
||||
"common.restartToUpdate": "برای بهروزرسانی مجدداً راهاندازی کنید",
|
||||
"context.copyImage": "کپی تصویر",
|
||||
"context.copyImageAddress": "کپی آدرس تصویر",
|
||||
"context.copyLink": "کپی لینک",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"common.checkUpdates": "Vérifier les mises à jour...",
|
||||
"common.checkingUpdates": "Vérification des mises à jour...",
|
||||
"common.downloadingUpdate": "Téléchargement de la mise à jour...",
|
||||
"common.isLatestVersion": "Déjà à jour",
|
||||
"common.restartToUpdate": "Redémarrer pour mettre à jour",
|
||||
"context.copyImage": "Copier l'image",
|
||||
"context.copyImageAddress": "Copier l'adresse de l'image",
|
||||
"context.copyLink": "Copier le lien",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"common.checkUpdates": "Controlla aggiornamenti...",
|
||||
"common.checkingUpdates": "Controllo aggiornamenti in corso...",
|
||||
"common.downloadingUpdate": "Download aggiornamento in corso...",
|
||||
"common.isLatestVersion": "Già aggiornato",
|
||||
"common.restartToUpdate": "Riavvia per aggiornare",
|
||||
"context.copyImage": "Copia immagine",
|
||||
"context.copyImageAddress": "Copia indirizzo immagine",
|
||||
"context.copyLink": "Copia link",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"common.checkUpdates": "更新を確認しています...",
|
||||
"common.checkUpdates": "更新を確認...",
|
||||
"common.checkingUpdates": "更新を確認しています...",
|
||||
"common.downloadingUpdate": "更新をダウンロード中...",
|
||||
"common.isLatestVersion": "すでに最新です",
|
||||
"common.restartToUpdate": "更新を完了するには再起動してください",
|
||||
"context.copyImage": "画像をコピー",
|
||||
"context.copyImageAddress": "画像のアドレスをコピー",
|
||||
"context.copyLink": "リンクをコピー",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"common.checkUpdates": "업데이트 확인 중...",
|
||||
"common.checkUpdates": "업데이트 확인...",
|
||||
"common.checkingUpdates": "업데이트 확인 중...",
|
||||
"common.downloadingUpdate": "업데이트 다운로드 중...",
|
||||
"common.isLatestVersion": "이미 최신 버전입니다",
|
||||
"common.restartToUpdate": "업데이트를 완료하려면 다시 시작하세요",
|
||||
"context.copyImage": "이미지 복사",
|
||||
"context.copyImageAddress": "이미지 주소 복사",
|
||||
"context.copyLink": "링크 복사",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"common.checkUpdates": "Updates controleren...",
|
||||
"common.checkingUpdates": "Updates controleren...",
|
||||
"common.downloadingUpdate": "Update downloaden...",
|
||||
"common.isLatestVersion": "Al up-to-date",
|
||||
"common.restartToUpdate": "Herstarten om bij te werken",
|
||||
"context.copyImage": "Afbeelding kopiëren",
|
||||
"context.copyImageAddress": "Afbeeldingsadres kopiëren",
|
||||
"context.copyLink": "Link kopiëren",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"common.checkUpdates": "Sprawdzanie aktualizacji...",
|
||||
"common.checkUpdates": "Sprawdź aktualizacje...",
|
||||
"common.checkingUpdates": "Sprawdzanie aktualizacji...",
|
||||
"common.downloadingUpdate": "Pobieranie aktualizacji...",
|
||||
"common.isLatestVersion": "Masz najnowszą wersję",
|
||||
"common.restartToUpdate": "Uruchom ponownie, aby zaktualizować",
|
||||
"context.copyImage": "Kopiuj obraz",
|
||||
"context.copyImageAddress": "Kopiuj adres obrazu",
|
||||
"context.copyLink": "Kopiuj link",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"common.checkUpdates": "Verificando atualizações...",
|
||||
"common.checkUpdates": "Verificar atualizações...",
|
||||
"common.checkingUpdates": "Verificando atualizações...",
|
||||
"common.downloadingUpdate": "Baixando atualização...",
|
||||
"common.isLatestVersion": "Já está atualizado",
|
||||
"common.restartToUpdate": "Reinicie para atualizar",
|
||||
"context.copyImage": "Copiar Imagem",
|
||||
"context.copyImageAddress": "Copiar Endereço da Imagem",
|
||||
"context.copyLink": "Copiar Link",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"common.checkUpdates": "Проверка обновлений...",
|
||||
"common.checkUpdates": "Проверить обновления...",
|
||||
"common.checkingUpdates": "Проверка обновлений...",
|
||||
"common.downloadingUpdate": "Загрузка обновления...",
|
||||
"common.isLatestVersion": "Уже актуальная версия",
|
||||
"common.restartToUpdate": "Перезапустите для обновления",
|
||||
"context.copyImage": "Копировать изображение",
|
||||
"context.copyImageAddress": "Копировать адрес изображения",
|
||||
"context.copyLink": "Копировать ссылку",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"common.checkUpdates": "Güncellemeleri kontrol et...",
|
||||
"common.checkingUpdates": "Güncellemeler kontrol ediliyor...",
|
||||
"common.downloadingUpdate": "Güncelleme indiriliyor...",
|
||||
"common.isLatestVersion": "Zaten güncel",
|
||||
"common.restartToUpdate": "Güncellemek için yeniden başlatın",
|
||||
"context.copyImage": "Resmi Kopyala",
|
||||
"context.copyImageAddress": "Resim Adresini Kopyala",
|
||||
"context.copyLink": "Bağlantıyı Kopyala",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user