Compare commits

..

44 Commits

Author SHA1 Message Date
rdmclin2 a35a69d1e2 chore: remove database migration 2026-03-18 13:23:26 +08:00
rdmclin2 0b3713d79a chore: bot architecure refact 2026-03-18 12:48:11 +08:00
rdmclin2 b65c06a02f fix: shared redis proxy 2026-03-12 21:43:19 +08:00
rdmclin2 2027df3d30 fix: lint error 2026-03-12 21:03:48 +08:00
rdmclin2 54e443bd55 chore: update platfom icon color 2026-03-12 21:02:00 +08:00
rdmclin2 3de1a4e412 chore: use lobe channel icon 2026-03-12 21:01:20 +08:00
rdmclin2 69ba6e8714 chore: update memory tool icon 2026-03-12 20:07:20 +08:00
rdmclin2 5e39345c8d fix: edit messsage throw error 2026-03-12 19:53:22 +08:00
rdmclin2 185e598532 fix: discord threadId bypass 2026-03-12 19:39:05 +08:00
rdmclin2 e680dd9b7c fix: discord metion thread 2026-03-12 19:17:38 +08:00
rdmclin2 c2dae40303 fix: crypto algorithm 2026-03-12 19:17:38 +08:00
rdmclin2 d43dd2d7e0 docs : add qq channel 2026-03-12 19:17:38 +08:00
rdmclin2 265b39615d feat: support QQ platform 2026-03-12 19:17:38 +08:00
rdmclin2 2b46f65571 chore: refactor platform abstract 2026-03-12 19:17:38 +08:00
rdmclin2 802a8aee64 chore: add bot platform abstract 2026-03-12 19:17:38 +08:00
LiJian 4065dc0565 🐛 fix: improve skill exec script way (#12926)
* fix: add the activatedSkills to improve the execScripte tools

* feat: change the activePath into call market endpoint

* fix: clean the code

* feat: fixed the execScript in desktop ts error
2026-03-12 17:29:59 +08:00
Rdmclin2 3529b46f2c 💄 style: restore foot gap (#12936)
chore: add back padding
2026-03-12 17:17:06 +08:00
Innei 8b29bb7fc9 feat: preload bundled i18n resources and lazy-load target language (#12929)
 feat: preload bundled i18n resources synchronously and reload actual language in background

For non-default languages, preload bundled en-US resources synchronously to avoid
Suspense on first render, then reload the user's actual language from backend
in the background. This ensures instant rendering with fallback text while the
correct translations load asynchronously.
2026-03-12 16:42:03 +08:00
Rdmclin2 804eb57dd8 💄 style: fix skill banner gap and apporve mode icon style (#12930)
* fix: skill banner style and footer runtime config

* fix:  approval mode icon style fix
2026-03-12 15:33:08 +08:00
Arvin Xu 2399f672e2 feat: add lobehub skill (#12922)
* add builtin lobehub skills

* refactor cloud sandbox

* refactor cloud sandbox

* improve styles
2026-03-12 14:00:35 +08:00
Arvin Xu 9c9e8e8ece 🐛 fix: tool engine and input-loading (#12908)
* 🐛 fix: ensure always-on builtin tools and user-selected plugins are enabled in tool engine

- Add alwaysOnToolIds (lobe-tools, lobe-skills) that are always enabled regardless of user selection
- Include user-selected plugins in enableChecker rules for both frontend and server-side tool engines
- Change enableCheckerFactory default from enabled to disabled (tools must be explicitly enabled via rules)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* 🐛 fix: improve input loading state to cover sendMessage through AI generation

- Add isInputLoading state that includes sendMessage operation type, so input stays
  in loading state from the moment user sends until AI finishes generating
- Add INPUT_LOADING_OPERATION_TYPES constant (superset of AI_RUNTIME_OPERATION_TYPES + sendMessage)
- Update ChatInput to use isInputLoading instead of isAIGenerating for disable/loading state
- Update stopGenerating to cancel all input-loading operations and restore editor on cancel

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

*  test: fix stopGenerating tests to match updated action implementation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix agent

* 🐛 fix: add missing selector mocks in toolEngineering tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:35:48 +08:00
Zhijie He 2e45e24df3 💄 style: use Response API for Grok as default (#12843)
* sytle: use Response API for Grok

* chore: add unit test for response api only, cleanup xai unit test
2026-03-12 11:22:20 +08:00
Zhijie He fded8dbb4e 🔨 chore: extend video_url support for OpenAI SDK (#12885)
* style: update moonshot models

* 🔨 chore: extend `video_url` support for OpenAI SDK

* fix: fix ci error

* hotfix: fix sensenova baseUrl error

* fix: fix kimi-k2.5 video tag from LobeHub

* fix: wenxin flag

* chore: cleanup utils

* style: add video tag for `glm-4.1/4.5v`

remove video tag for sensenova due to not support in OpenAI mode
2026-03-12 11:20:48 +08:00
LobeHub Bot 709c9749d0 🌐 chore: translate non-English comments to English in packages/openapi/src (#12873)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Arvin Xu <arvinx@foxmail.com>
2026-03-12 11:19:33 +08:00
sxjeru c07574af12 🔧 chore: refactor build scripts to prevent Vercel OOM (#12912)
* ♻️ refactor: update build scripts for improved performance and consistency

* 🐛 fix: update build:spa script to use pnpm for improved consistency
2026-03-12 10:39:29 +08:00
Arvin Xu b4624e6515 🔨 chore: add Response API support (#12918)
* add response api framework

* finish response api structure

* finish response api structure

*  feat: implement basic text generation for Response API (LOBE-5858)

- Add instructions extraction from system/developer input messages
- Add instructions param to ExecAgentParams, append to agent systemRole
- Implement extractPrompt, extractAssistantContent, extractUsage in ResponsesService
- Wire up execAgent + executeSync flow for non-streaming and streaming
- Add logprobs field to output_text content parts for schema compliance
- Fix truncation field to output string enum instead of object

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

*  feat: implement real token-level streaming for Response API (LOBE-5859)

- Replace fake streaming (executeSync → emit events) with real streaming
- Subscribe to InMemoryStreamEventManager for live stream_chunk events
- Run executeSync in background, convert text chunks to output_text.delta SSE events
- Add missing schema fields: item_id on content_part/text events, logprobs on delta/done events
- Fix content_part.added/done to include item_id per OpenResponses spec

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

*  feat: implement tool calling output extraction for Response API (LOBE-5860)

- Add extractOutputItems to convert AgentState messages to OpenResponses output items
- Extract assistant tool_calls → function_call output items
- Extract tool result messages → function_call_output output items
- Skip message items for assistant messages that have tool_calls (avoid duplicates)
- Add status field to function_call_output items per OpenResponses spec
- Update FunctionCallOutputItemSchema with optional status field
- Output array reflects execution order: function_call → function_call_output → message

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

*  feat: implement multi-turn conversations via previous_response_id (LOBE-5861)

Encode topicId in response.id to enable stateless multi-turn conversation
chaining. When previous_response_id is provided, extract topicId and pass
to execAgent via appContext, which automatically loads history messages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* 🐛 fix: add missing type fields for OpenResponses compliance (logprobs, item_id, input_tokens_details)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:39:08 +08:00
Arvin Xu f94f1ae08a feat(cli): CLI Phase 5 - agent KB/file/pin, thread, eval and miscellaneous command enhancements (#12920)
*  feat(cli): CLI Phase 5 - agent KB/file/pin, thread management, eval expansion

- Add agent subcommands: pin/unpin, kb-files, add-file/remove-file/toggle-file, add-kb/remove-kb/toggle-kb
- Create thread command with list/list-all/delete subcommands
- Expand eval with internal benchmark/dataset/testcase/irun management
- Move existing external eval commands under `eval ext` namespace
- Add comprehensive unit tests for all new functionality

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* 💄 style(cli): rename eval `irun` to `run` since external moved to `ext` namespace

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ♻️ refactor(cli): merge external eval commands into unified tree with --external flag

Remove separate `eval ext` namespace; use `--external` flag on overlapping commands
(dataset get, run get) and integrate external-only commands directly into the tree.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

*  feat(cli): CLI Phase 6 - miscellaneous command enhancements

- file: add upload (hash check + create), edit (move to folder), kb-items
- user: new command with info, settings, preferences, update-avatar, update-name
- model: add batch-update, sort order
- plugin: add create (without settings, distinct from install)
- generation: add delete

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 09:47:16 +08:00
Arvin Xu 165697ce47 feat(cli): CLI Phase 4 - cron, message, topic share, agent-group, session-group (#12915)
*  feat(cli): CLI Phase 4 - cron, message enhance, topic share, agent-group, session-group

Add core commands to complete CLI coverage of TRPC routers:

- `lh cron` — Agent cron job management (list/view/create/edit/delete/toggle/reset/stats)
- `lh message` — Enhanced with create/edit/add-files/word-count/rank-models/delete-by-assistant/delete-by-group
- `lh topic` — Enhanced with clone/share/unshare/share-info/import
- `lh agent-group` — Agent group management (list/view/create/edit/delete/duplicate/add-agents/remove-agents)
- `lh session-group` — Session group management (list/create/edit/delete/sort)

Closes LOBE-5920

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* update version

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 00:32:00 +08:00
Rdmclin2 14dd5d09dd feat: support runtime config (#12902)
* feat: support runtime config

* fix: cloud sandbox default tool ids
2026-03-11 23:43:33 +08:00
Innei 21d1f0e472 feat(settings): improve tool detector display layout (#12906)
*  feat(settings): improve tool detector display layout

- Move version to left side with Name, display as Tag
- Right side: two lines (Available status + path), right-aligned
- Unavailable: single line centered
- Add runtime environment detectors (Node, Python, npm)
- Add i18n for system tools settings

Made-with: Cursor

* 🔧 fix(toolDetectors): ensure successful version check for Python runtime

- Update pythonDetector to enforce successful invocation of `--version` for confirming usable runtime.
- Removed redundant version handling logic to streamline the detection process.

Signed-off-by: Innei <tukon479@gmail.com>

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-03-11 19:55:36 +08:00
Rdmclin2 bc50db6a8b 🐛 fix: desktop advanced mode (#12911)
* fix: advanced mode empty

* fix: desktop channel router lost
2026-03-11 19:02:37 +08:00
LobeHub Bot 8db8dff7b0 test: add unit tests for MarketService (#12905)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 15:51:25 +08:00
LiJian 1a3c561e21 💄 style: add the history count limit back in agents params settings (#12199)
* fix: add the history count limit back in agents params settings

* fix: fixed the test

* fix: change the default settings snap the enableHistoryCount as false

* fix: change the history process to the first into MessageEngine

* fix: fixed some count limited

* fix: fixed the enableHistoryCount check test

* fix: change the getEnableHistoryCountById logic
2026-03-11 15:46:56 +08:00
Arvin Xu 8e60b9f620 feat(cli): CLI Phase 3 - bot integration, search & device (#12904)
* fix cli alias

* 🐛 fix(cli): fix gen text non-streaming mode and streaming SSE parsing

- Add `responseMode: 'json'` for non-streaming requests to get plain JSON instead of SSE
- Fix streaming SSE parser to handle LobeHub's JSON string format (e.g. `"Hello"`)
- Support both OpenAI and Anthropic response formats in non-streaming mode
- Add E2E tests for all generate commands (text, list, tts, asr, alias)
- Update skills knowledge.md docs with new kb commands

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

*  feat(cli): unify skill install command and add e2e tests

Merge import-github/import-url/import-market into a single `skill install <source>` command with auto-detection (GitHub URL/shorthand, ZIP URL, or marketplace identifier). Add alias `skill i`. Add comprehensive e2e and unit tests for skill commands.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* 🔨 chore: fix linter formatting in memory e2e test

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* 🐛 fix: add vitest-environment node declaration to aiProvider test

Fix server-side env variable access error by declaring node environment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix cli review

* fix test

*  feat(cli): add web search and crawl support to search command

Add --web flag for web search via tools TRPC client, and search view
subcommand for viewing results (URLs via crawl, local resources by type:id).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

*  feat(cli): add device management command with TRPC endpoints

Add `lh device` command for managing connected devices via server-side
TRPC API, complementing the existing `lh connect` (device-as-client).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

*  feat(cli): add bot integration management command

Add `lh bot` top-level command for managing agent bot integrations
(Discord, Slack, Telegram, Lark/Feishu). Includes list, view, add,
update, remove, enable/disable, and connect subcommands.

Also adds `list` procedure to agentBotProvider TRPC router for
querying all bots with optional agent/platform filters.

Closes LOBE-5900

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 14:29:15 +08:00
Innei 874c2dd706 🐛 fix(i18n): preload default language from JSON to avoid Suspense on first render (#12895)
* 🐛 fix(i18n): preload default language from JSON to avoid Suspense on first render

- Sync load en-US common/error/chat from locales/en-US/*.json
- Use JSON (not locales/default/*.ts) as runtime values - TS source is type-only
- Prevents useTranslation from suspending, avoids CLS from 44px skeleton fallback

Made-with: Cursor

*  feat(i18n): enable partial loading of languages and add tests for dynamic namespace loading

Signed-off-by: Innei <tukon479@gmail.com>

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-03-11 14:00:39 +08:00
LobeHub Bot 4988413d58 🌐 chore: translate non-English comments to English in src/features/Electron (#12901)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 13:43:39 +08:00
YuTengjing f1dd2fc458 📝 docs: add catch error logging rule to TypeScript skill (#12903) 2026-03-11 12:10:36 +08:00
Arvin Xu aa8082d6b2 feat: lobehub cli for better agency agent (#12897)
* fix cli alias

* 🐛 fix(cli): fix gen text non-streaming mode and streaming SSE parsing

- Add `responseMode: 'json'` for non-streaming requests to get plain JSON instead of SSE
- Fix streaming SSE parser to handle LobeHub's JSON string format (e.g. `"Hello"`)
- Support both OpenAI and Anthropic response formats in non-streaming mode
- Add E2E tests for all generate commands (text, list, tts, asr, alias)
- Update skills knowledge.md docs with new kb commands

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

*  feat(cli): unify skill install command and add e2e tests

Merge import-github/import-url/import-market into a single `skill install <source>` command with auto-detection (GitHub URL/shorthand, ZIP URL, or marketplace identifier). Add alias `skill i`. Add comprehensive e2e and unit tests for skill commands.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* 🔨 chore: fix linter formatting in memory e2e test

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* 🐛 fix: add vitest-environment node declaration to aiProvider test

Fix server-side env variable access error by declaring node environment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix cli review

* fix test

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 11:06:52 +08:00
YuTengjing 37cb4983de 🐛 fix: filter out delisted lobehub provider models from DB residuals (#12896) 2026-03-11 10:22:51 +08:00
Innei 9098d0074a ♻️ refactor(desktop): move onboarding state to main process (#12890)
* refactor: desktop onboarding

* ♻️ refactor(desktop): reinstate onboarding guard before auto OIDC

- Add getDesktopOnboardingCompleted/setDesktopOnboardingCompleted back to localStorage
- These functions persist across sign-out, preventing unexpected OIDC popups
- Fix for Codex review feedback on PR #12890

* ♻️ refactor(desktop): use sessionStorage for onboarding completed flag

*  test(desktop): fix BrowserManager test for async initializeBrowsers
2026-03-11 00:36:05 +08:00
Arvin Xu 860e11ab3a ♻️ refactor(cli): extract shared @lobechat/local-file-shell package (#12865)
* ♻️ refactor(cli): extract shared @lobechat/local-file-shell package

Extract common file and shell operations from Desktop and CLI into a
shared package to eliminate ~1500 lines of duplicated code. CLI now
uses @lobechat/file-loaders for rich format support (PDF, DOCX, etc.).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* update

* update commands

* update version

* update deps

* refactor version issue

*  feat(local-file-shell): add cwd support, move/rename ops, improve logging

- Add missing `cwd` parameter to `runCommand` (align with Desktop)
- Add `moveLocalFiles` with batch support and detailed error handling
- Add `renameLocalFile` with path validation and traversal prevention
- Add error logging in shell runner's error/completion handlers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* support update model and provider in cli

* fix desktop build

* fix

* 🐛 fix: pin fast-xml-parser to 5.4.2 in bun overrides

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 00:04:22 +08:00
YuTengjing c2e9b45d4c feat: add InsufficientBudget error type and Pro badge i18n (#12886) 2026-03-10 23:43:24 +08:00
YuTengjing 8063378a1d 🐛 fix: resolve ModelSelect crash and update default model (#12892) 2026-03-10 21:10:11 +08:00
Innei 93aed84399 🔨 chore(i18n): sync locale files across desktop and web (#12887)
Made-with: Cursor
2026-03-10 19:23:47 +08:00
570 changed files with 22912 additions and 8196 deletions
+231
View File
@@ -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)
+144
View File
@@ -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.
+246
View File
@@ -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`.
+281
View File
@@ -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) |
+138
View File
@@ -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`
+1
View File
@@ -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
+135
View File
@@ -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);
});
});
});
+286
View File
@@ -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');
});
});
});
+93
View File
@@ -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);
});
});
});
+119
View File
@@ -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);
});
});
});
+252
View File
@@ -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();
});
});
});
+177
View File
@@ -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 },
);
+98
View File
@@ -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();
});
});
});
+205
View File
@@ -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);
});
});
});
+73
View File
@@ -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();
});
});
});
+220
View File
@@ -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();
});
});
});
+55
View File
@@ -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();
});
});
+181
View File
@@ -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();
});
});
});
+116
View File
@@ -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)');
});
});
});
+8 -5
View File
@@ -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",
+2
View File
@@ -1,3 +1,5 @@
packages:
- '../../packages/device-gateway-client'
- '../../packages/local-file-shell'
- '../../packages/file-loaders'
- '.'
+38 -6
View File
@@ -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;
}
+2 -1
View File
@@ -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
+12
View File
@@ -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);
+178
View File
@@ -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',
});
});
});
});
+215
View File
@@ -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)}`,
);
},
);
}
+208
View File
@@ -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
View File
@@ -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
+345
View File
@@ -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);
});
});
});
+298
View File
@@ -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}`);
}
});
}
+28 -16
View File
@@ -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
View File
@@ -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)',
});
});
}
+172
View File
@@ -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();
});
});
});
+271
View File
@@ -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}`);
}
});
}
+97
View File
@@ -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}`);
});
}
+324 -19
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
});
}),
);
}
+103
View File
@@ -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([
+111
View File
@@ -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
+15
View 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({
+106 -1
View File
@@ -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')
+15 -4
View File
@@ -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
+13 -5
View File
@@ -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.'),
);
},
);
}
+8 -1
View File
@@ -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
View File
@@ -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)}`,
);
});
}
+6 -16
View File
@@ -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);
+7 -3
View File
@@ -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();
});
});
+221 -10
View File
@@ -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
+255
View File
@@ -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'));
});
});
});
+186 -2
View File
@@ -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)}`);
});
}
+45
View File
@@ -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({});
+50
View File
@@ -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
+201
View File
@@ -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', () => {
+205 -2
View File
@@ -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
View File
@@ -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);
}
}
+139
View File
@@ -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 },
],
});
});
});
});
+120
View File
@@ -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)`);
});
}
+87 -13
View File
@@ -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', () => {
+48 -43
View File
@@ -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 ─────────────────────────────────────────
+121
View File
@@ -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,
});
});
});
});
+99
View File
@@ -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
View File
@@ -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
+191
View File
@@ -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);
});
});
});
+123
View File
@@ -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
View File
@@ -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);
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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';
+30 -202
View File
@@ -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
View File
@@ -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 │
└────────────┴────────────────────────┴───────────┴─────────┴───────────┴──────────┴───────┘"
`;
+129
View File
@@ -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();
});
});
+333 -6
View File
@@ -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 BF, 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) => {
+1
View File
@@ -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/*"]
}
},
+8 -1
View File
@@ -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',
});
+8
View File
@@ -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: {
+1
View File
@@ -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",
+1
View File
@@ -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