Compare commits

...

83 Commits

Author SHA1 Message Date
arvinxx 4a2c304eb5 ♻️ 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>
2026-03-12 01:36:18 +08:00
arvinxx 2a336ddf20 💄 style(cli): rename eval irun to run since external moved to ext namespace
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 01:26:18 +08:00
arvinxx a17cbf9bb7 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>
2026-03-12 01:07:10 +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
LiJian eec8e113fc ♻️ refactor: add the skills in community pages (#12761)
* feat: add the skills in community pages

* feat: add some skills & import the import routes

* feat: add detail used pages & prompt

* feat: add the skill sort way

* fix: ts fixed

* fix: ts fixed

* fix: test fixed

* fix: test fixed
2026-03-10 18:00:15 +08:00
Sun13138 826a099f8d 🐛 fix: harden market auth popup handoff and storage fallback (#12863)
* 🐛 fix: make market auth popup handoff COOP-safe

* 🐛 fix: harden market auth popup handoff flow

* 🐛 fix: guard market auth handoff storage access
2026-03-10 17:19:22 +08:00
Innei c087134953 feat(desktop): unify canary with stable app name/icon, add channel tag in About (#12881)
- Use same app name (LobeHub) and icon as stable for canary builds
- Add build channel tag in Settings > About for non-stable channels (Canary, Nightly, Beta)
- Add getBuildChannel IPC to expose build-time channel for display

Made-with: Cursor
2026-03-10 16:41:56 +08:00
Innei 5e468cd850 feat(agent-browser): add browser automation skill and tool detection (#12858)
*  feat(tool-detectors): add browser automation support and refactor tool detector categories

- Introduced browser automation detectors to the tool detector manager.
- Updated tool categories to include 'browser-automation'.
- Refactored imports to use type imports where applicable for better clarity.
- Cleaned up unnecessary comments in tool filters.

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

* 🔧 chore: add browser automation tool detection UI

* 🔧 chore: update react-scan version and enhance agent-browser documentation

- Updated `react-scan` dependency from version 0.4.3 to 0.5.3 in package.json.
- Improved documentation in `content.ts` for the agent-browser, clarifying command usage and workflows.
- Added development mode flag `__DEV__` in sharedRendererConfig for better environment handling.
- Integrated `scan` functionality in `initialize.ts` to enable scanning in development mode.
- Updated global type definitions to include `__DEV__` constant for clarity.

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

* 🔧 chore(builtin-skills): add dependency and refactor skill filtering logic

- Added `@lobechat/const` as a dependency in package.json.
- Introduced a new function `shouldEnableBuiltinSkill` to determine if a skill should be enabled based on the environment.
- Refactored the `builtinSkills` export to filter skills using the new logic.

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

* 🔧 chore(builtin-skills): refactor skill management and add filtering logic

- Removed unnecessary dependency from package.json.
- Simplified skill filtering logic by introducing `filterBuiltinSkills` and `shouldEnableBuiltinSkill` functions.
- Updated various components to utilize the new filtering logic for managing builtin skills based on the environment.

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

*  feat(builtin-skills): introduce new skill APIs and refactor manifest structure

- Added new APIs for skill management: `runSkillApi`, `readReferenceApi`, and `exportFileApi` to enhance functionality.
- Created a base manifest file (`manifest.base.ts`) to centralize API definitions.
- Updated the desktop manifest (`manifest.desktop.ts`) to utilize the new base APIs.
- Refactored existing manifest to streamline API integration and improve maintainability.
- Introduced a detailed system prompt for better user guidance on skill usage.

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

*  feat: desktop skill runtime, skill store inspectors, and tool UI updates

Made-with: Cursor

*  feat: enhance skill import functionality and testing

- Updated `importFromUrl` method in `SkillImporter` to accept additional options for identifier and source.
- Modified `importFromMarket` in `agentSkillsRouter` to utilize the new options for better tracking of skill imports.
- Added integration tests to ensure stable behavior when re-importing skills from the market, verifying that identifiers remain consistent across imports.

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

* 🔧 chore: update .gitignore and package.json dependencies

- Added 'bin' to .gitignore to exclude binary files from version control.
- Included 'fflate' as a new dependency in package.json to support file compression in the application.
- Updated writeFile method in LocalFileCtr to handle file content as Uint8Array for improved type safety.

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

* 🔧 chore: update package.json dependencies

- Removed 'fflate' from dependencies and added it to devDependencies for better organization.
- Ensured proper formatting by adding a newline at the end of the file.

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

*  feat: add agent-browser download script and integrate binary handling

- Introduced a new script to download the `agent-browser` binary, ensuring it is available for the application.
- Updated `electron-builder.mjs` to include the binary in the build process.
- Modified `dir.ts` to define the binary directory path based on the packaging state.
- Enhanced the `App` class to set environment variables for the agent-browser integration.

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

*  feat: add DevTools toggle to Linux and Windows menus

- Introduced a new menu item for toggling DevTools with the F12 accelerator key in both Linux and Windows menu implementations.
- Added a separator for better organization of the view submenu items.

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

*  feat: integrate agent-browser binary download into build process

- Added functionality to download the `agent-browser` binary during the build process in `electron-builder.mjs`.
- Enhanced the download script with detailed logging for better visibility of the download status and errors.
- Updated the `App` class to log the binary directory path for improved debugging.
- Reintroduced the `AuthRequiredModal` in the layout for desktop users.

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

* fix: mock binary directory path in tests

- Added a mock for the binary directory path in the App tests to facilitate testing of the agent-browser integration.
- This change enhances the test environment by providing a consistent path for the binary during test execution.

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

* 🐛 fix: improve authorization notification handling

- Updated the `notifyAuthorizationRequired` method to implement trailing-edge debounce, ensuring that rapid 401 responses are coalesced and the IPC event is sent after the burst settles.
- Refactored the notification logic to enhance clarity and maintainability.

 feat: add desktop onboarding redirect

- Introduced a `useEffect` hook in `StoreInitialization` to redirect users to the `/desktop-onboarding` page if onboarding is not completed, ensuring a smoother user experience on fresh installs.

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

* 🐛 fix(desktop): hide Agent Browser skill on Windows

Made-with: Cursor

* 🔧 chore: update memory limits for build processes

- Increased the `NODE_OPTIONS` memory limit for both `build:next` and `build:spa` scripts from 6144 to 7168, optimizing build performance and resource management.

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

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-03-10 16:13:33 +08:00
Arvin Xu eb7cf10ff9 test: fix GatewayManager tests to include platform parameter (#12876)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 14:39:00 +08:00
lobehubbot 7d88b8cda5 Merge remote-tracking branch 'origin/main' into canary 2026-03-10 06:35:39 +00:00
YuTengjing 258e9cb982 👷 build: add migration to enable pg_search extension (#12874)
*  feat: add migration to enable pg_search extension

* 🐛 fix: skip pg_search migration for PGlite test compatibility
2026-03-10 14:34:42 +08:00
sxjeru a7d896843f 💄 style: Add new GPT-5.4 model (#12654)
*  feat(openai): add GPT-5.3 Chat model with enhanced features and pricing details

*  feat: add Codex Max Reasoning Effort parameter and slider component for enhanced model configuration

*  feat: update Qwen model configurations and add new Qwen3.5 models with detailed descriptions and pricing

*  feat: add GPT-5.4 and GPT-5.4 pro models with pricing and capabilities to the model bank

*  feat: add GPT-5.4, GPT-5.4 pro, and GPT-5.3 Chat models with detailed capabilities and pricing to the model bank

*  feat: 更新 zhipu 聊天模型的定价参数,移除不必要的 textOutput 参数

*  feat: 移除 Gemini 3 Pro 模型的详细信息,标记为已弃用
2026-03-10 09:59:14 +08:00
Hardy 7de2a68d20 feat(siliconcloud): add Qwen3.5 series models (#12785) 2026-03-10 09:58:37 +08:00
LobeHub Bot e753856abf test: add unit tests for gateway service (#12784)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 09:58:13 +08:00
René Wang b94503db8b 📝 docs: upgrade usage docs with improved structure and content (#12704)
Adopt Mintlify-quality writing patterns across 11 existing docs and add 3 new docs.
Adds Steps, Tabs, AccordionGroup, and mermaid diagrams for better readability.

Priority 1 (major expansion): agent-market, resource, scheduled-task, mcp-market
Priority 2 (structural): memory, web-search, tts-stt, vision, chain-of-thought
Priority 3 (minor): artifacts, agent
New docs: chat, file-upload, skills-and-tools

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 09:56:39 +08:00
Marcell Gu 023e3ef11a 📝 docs: Simplify docker compose network architecture & Remove broken links from docker compose docs (#12749)
* ♻️ refactor(docker): simplify network architecture and add admin port
- Remove unnecessary network-service (alpine) container
- Use dedicated lobe-network bridge for all services
- Add RUSTFS_ADMIN_PORT environment variable for admin console
- Update container-to-container communication to use Docker service names
- Use relative path volumes for better data persistence

* 📝 docs: update Docker Compose deployment guide
- Add single-domain deployment documentation
- Update INTERNAL_APP_URL guidance
- Clarify Port Mode vs Domain Mode behavior
- Add S3_ENDPOINT configuration tips
- Remove broken link to non-existent server-database documentation

* fix(docker): keep backward-compatible volume paths for existing deployments

- PostgreSQL: Keep ./data (not ./postgres_data)
- Redis: Keep redis_data named volume (not ./redis_data)
- RustFS: Keep rustfs-data named volume (not ./rustfs_data)

This ensures existing users can upgrade without data migration.

* fix(docker): correct Port Mode vs Domain Mode description

- Fix reversed explanation in comments
- Port Mode: Uses default ports (3210/9000/9001)
- Domain Mode: Custom ports via reverse proxy

This aligns with the actual deployment script behavior.
2026-03-10 09:55:56 +08:00
Rylan Cai ea329113be feat(eval): add external scoring mode (#12729)
* wip: add llm relevant & BrowseComp

* wip: add widesearch desc

* wip: dsqa, hle, widesearch

* wip: add dsqa

* wip: add awaiting eval status for runs

* wip: add awaiting status for run

* wip: adjust hle-verified

* 🐛 fix: browsecomp topics

* 📝 docs: add annotations

* wip: add awaiting status for pass@k

* wip: add complete status

* wip: update theard dots

* wip: update run status page

* wip: remove useless impl

* wip: update prompt

*  feat: add external eval routes

* wip: add eval cli

* 🐛 fix: support authoritize in no browser environment

* wip: pass tests

* ♻️ refactor: remove tests

* ♻️ refactor: mo camel case
2026-03-10 09:53:26 +08:00
Innei 255a1c21a8 🐛 fix: redirect to desktop onboarding when not completed (#12866)
* 🐛 fix: redirect to desktop onboarding when not completed

Desktop app was missing the redirect to `/desktop-onboarding` when
onboarding hadn't been completed. The `useDesktopUserStateRedirect`
callback silently returned instead of navigating, causing:
- Users never see the onboarding flow on fresh install
- `AuthRequiredModal` suppressed because onboarding guard fails

* 🐛 fix: remove desktop onboarding routes from proxy configuration

The `/desktop-onboarding` and its regex route have been removed from the proxy configuration. This change simplifies the routing logic as the onboarding flow is now handled directly in the user state redirect logic.

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

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-03-10 02:15:27 +08:00
Zephyr 81d25bf124 feat: add v1 api (#12758)
*  feat(openapi): add API key hash support for secure storage

*  feat(openapi): enhance message translation and knowledge base functionality

- Added MessageTranslationController and associated routes for managing message translations, including fetching, creating, updating, and deleting translations.
- Introduced KnowledgeBaseController with routes for CRUD operations on knowledge bases, including file management and access control.
- Updated existing message and translation routes to improve structure and naming consistency.
- Refactored related services and types to support new features and ensure type safety.

This update enhances the API's capabilities for handling message translations and knowledge base management, improving overall functionality and user experience.

* fix: allow OWNER scope to list agents in agents route

- Add OWNER scope to AGENT_READ permission check
- Aligns list behavior with AgentService.queryAgents ownership filter
- Allows owner-scoped users to list their own agents

* 🔧 refactor(rbac): improve import structure in rbac.ts

- Changed import statements to separate type imports from regular imports for better clarity and organization.
- This refactor enhances code readability and maintains consistency in the import structure.

* fix: 修复 chunk 服务与 async router 的循环依赖

- 将 createAsyncCaller 的静态导入改为动态导入 (await import)
- 打破 file.ts -> chunk/index.ts -> async/index.ts 的循环依赖链
- 使用 --skip-dynamic-imports 参数的 dpdm 验证循环依赖已解决

* 🐛 fix: resolve CI failures

* test: 补充 apiKey、KeyVaultsEncrypt、ChunkService 单测至 100% 覆盖率

- test(database): 补充 apiKey.ts query() 解密失败分支测试
- test(server): 补充 KeyVaultsEncrypt 非法密钥/密文格式 getUserKeyVaults 测试
- test(server): 新增 ChunkService 完整测试覆盖异步任务创建/触发/失败回写

所有新增测试通过 (46/46),目标文件覆盖率均达 100%
2026-03-10 01:00:36 +08:00
Rylan Cai 3894facf5f 🐛 fix(cli): require gateway for custom server (#12856)
* 🐛 fix(cli): require --gateway for custom server logins

* 🐛 fix(cli): persist custom server gateway settings

* ♻️ refactor(cli): centralize official endpoint urls
2026-03-10 00:02:51 +08:00
WangYK 473bc4e005 💄 style: support video input for SiliconCloud provider (#9988)
*  feat: support video input for SiliconCloud models

* 🐛 fix: resolve SSRF issue in video fetching; move message transformation to `context-builders`

* 🐛 fix: update MiniMax M2 context size

* 🐛 fix: use ssrf-safe-fetch in `videoUrlToBase64` and `imageUrlToBase64`

* 🐛 fix: fix tests

* 🐛 fix: dynamically import ssrf-safe-fetch to prevent build failures

* Revert "🐛 fix: dynamically import ssrf-safe-fetch to prevent build failures"

This reverts commit 5de0829527ae6dbdc78d694ccc9dca86f46e3168.

* chore: move `videoToBase64` to the `util` package

* fix: fix tests

* chore: update siliconcloud models

* fix: deduplicate siliconcloud models

* fix: videoUrlToBase64 should determine runtime when fetching

* fix: fix tests

* chore: update siliconcloud models

* chore: remove deprecated models

* chore: update model info

* fix: fix tests
2026-03-10 00:02:29 +08:00
lobehubbot 3cf4f28af0 🔖 chore(release): release version v2.1.39 [skip ci] 2026-03-09 15:07:18 +00:00
lobehubbot d54b30750a Merge remote-tracking branch 'origin/main' into canary 2026-03-09 15:05:28 +00:00
Arvin Xu 4e6790e3d7 👷 build: add api key hash column migration (#12862)
*  feat(database): extract openapi database changes

* 📝 docs: update db-migrations and version-release skills

---------

Co-authored-by: MarioJames <mocha.wyh@msn.com>
Co-authored-by: YuTengjing <ytj2713151713@gmail.com>
2026-03-09 23:04:45 +08:00
LobeHub Bot 8a679aa772 🌐 chore: translate non-English comments to English in src/app/(backend) (#12836)
🌐 chore: translate non-English comments to English in src/app/(backend) and src/app/[variants]/(auth)

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 20:03:13 +08:00
Arvin Xu 1329490306 feat(cli): add agent run and status commands (#12839)
*  feat(cli): add agent run and status commands

Implement `lh agent run` for executing agents with SSE streaming
and `lh agent status` for checking operation status. Includes
`--replay` option for offline replay from saved JSON fixtures.

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

* 🐛 fix(cli): preserve SSE frame state across read boundaries and enable verbose logging

- Move eventType/eventData outside the read loop so partial SSE frames
  split across chunks are not silently dropped
- Call setVerbose(true) when --verbose is passed so logger helpers
  actually print detailed tool arguments and results

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 20:02:41 +08:00
YuTengjing 228044e649 🐛 fix: add ffmpeg-static to default serverExternalPackages (#12846) 2026-03-09 18:13:17 +08:00
YuTengjing 857f469323 🐛 fix: remove ffmpeg-static from outputFileTracingExcludes (#12844) 2026-03-09 17:10:57 +08:00
Zhijie He 8d4d657a5d feat: add LongCat(美团) provider support (#12603)
* feat: add LongCat(美团) provider support

* chore: remove enable_thinking, due to not in doc anymore
2026-03-09 16:59:29 +08:00
Innei 50dbc653fa 🐛 fix: filter v-prefixed Docker tags in manifest creation (#12842) 2026-03-09 16:07:06 +08:00
YuTengjing 5af5b80b83 🐛 fix: include pnpm store path for ffmpeg-static in Vercel tracing (#12838) 2026-03-09 14:37:59 +08:00
Arvin Xu c6de80931e 🐛 fix: fix agent runtime error handle (#12834)
* improve inspect partial ability

* fix error

* fix runtime error
2026-03-09 12:24:13 +08:00
YuTengjing 6e26135978 🐛 fix: harden Anthropic message building and sampling parameter handling (#12827) 2026-03-09 11:05:02 +08:00
LobeHub Bot 10dfc6eec6 test: add unit tests for InMemoryAgentStateManager (#12377)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-09 10:40:34 +08:00
Hardy 8855ac3b8a feat: add new NVIDIA models and tweak the behavior of the enable thinking (#12533)
*  feat: add new NVIDIA models with thinking budget support

- Add 7 new models: MiniMax-M2.1, DeepSeek V3.2, GLM-4.7, GLM-5, Kimi K2.5, MiniMax-M2.5, Qwen3.5-397B-A17B
- Add thinkingBudget support for qwen3.5-397b-a17b model
- Update test case description

* 🐛 fix: remove thinking budget and add video support for Qwen3.5-397B-A17B
2026-03-09 10:34:00 +08:00
Zhijie He e4f8ed78ba 💄 style: add grok-4.20 series early support (#12743)
* style: add grok-4.20 series early support

* chore: disable browser request due to CORS

* style: update ability tag
2026-03-09 10:23:16 +08:00
Arvin Xu 4363994945 feat: support use remote device in IM integration (#12798)
* support timezone in system prompt

refactor to improve user prompts

refactor tool engine

refactor tools map mode

add bot callback service

clean

improve cli

update agentic tracing

refactor cli login

refactor cli

add device auth

improve device gateway implement

implement gateway pipeline

support device Gateway connect

support gateway

* revert electron device

* inject builtins agent prompts

* update tracing

* add testing

* refactor the activeDeviceId

* refactor BotCallbackService

* fix test and lint

* fix test and lint

* add tests

* fix tests

* fix lint
2026-03-09 01:17:56 +08:00
LobeHub Bot c1757e2e19 🌐 chore: translate non-English comments to English in GenerationItem (#12745)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 00:52:07 +08:00
LobeHub Bot 39e36320b2 🌐 chore: translate non-English comments to English in AgentSetting (#12807)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 23:59:44 +08:00
Arvin Xu ccd7f4e22b 🐛 fix(cli): fix type errors in generate image/video commands (#12828)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 23:36:08 +08:00
Rdmclin2 3f9c23e7b4 feat: support lark and feishu bot (#12712)
* feat: support lark and feishu

* chore: change integration to channel

* chore: rename from integration to channel

* fix: channel router

* feat: add topic list channel provider icon

* chore: update webhook url

* chore:  channel form refact

* chore: update i18n  keys to channel

* chore: update form item description

* style: hide required mark

* feat: add lark chat adapter

* chore: clean speaker tag  & add username api adapter

* chore: adjust topic channel icon

* chore: move developer mode to advanced setting

* chore: add lark icon

* fix: detail style

* fix: token check logic

* fix: encrpted risk

* fix: vercel function appId

* chore: remove webhook mode for discord

* chore: add doc link

* chore: add channel docs

* chore: remove unused import

* fix: create bot with wrong platform

* chore: update intergration to channel

* fix: udpate variable import

* fix: tsgo error

* chore: optimize webhook url trim

* chore: update copy text

* fix: telegram webhook not set

* chore: add persist logic

* docs: update feishu doc

* chore: update feishu and lark tenant

* chore: update docs

* chore: make verfication code required

* chore: update feishu docs

* chore: update verfication comment

* chore: update docs permission  list

* chore: verificationToken optional

* chore: update feishu and lark color

* chore: use test id
2026-03-08 19:18:06 +08:00
YuTengjing 15a95156f3 💄 style: update i18n locales (#12809) 2026-03-08 13:25:46 +08:00
YuTengjing f25edcc027 🔒 fix: add rate limit custom rules for password reset and email verification (#12808) 2026-03-08 12:40:14 +08:00
Arvin Xu e67bcb2571 feat(cli): add generate command for text/image/video/tts/asr (#12799)
*  feat(cli): add generate command for text/image/video/tts/asr

LOBE-5711

- `lh generate text <prompt>` — LLM text completion with SSE streaming
  - Supports --model (provider/model format), --system, --temperature, --pipe
- `lh generate image <prompt>` — Image generation via async task
- `lh generate video <prompt>` — Video generation via async task
- `lh generate tts <text>` — Text-to-speech (openai/microsoft/edge backends)
- `lh generate asr <file>` — Speech-to-text via OpenAI Whisper
- `lh generate status` — Check async generation task status
- `lh generate list` — List generation topics
- Add shared HTTP auth helper (api/http.ts) for webapi endpoints

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

* update info

* ♻️ refactor(cli): split generate command into submodules, text defaults non-streaming

- Split monolithic generate.ts into generate/{index,text,image,video,tts,asr}.ts
- Text subcommand now defaults to non-streaming (use --stream to opt in)
- Text subcommand supports --json for full JSON response output
- Video subcommand uses requiredOption for --model and --provider

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

* 🐛 fix(cli): read generation data from result.data and add required X-lobe-chat-auth header

Image/video mutations return { success, data: { ... } }, read IDs from data.
WebAPI endpoints require X-lobe-chat-auth (XOR-encrypted) alongside Oidc-Auth.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 11:19:01 +08:00
Zhijie He 2cce103137 💄 style: add qwen-image-2.0 series support (#12771) 2026-03-08 10:33:48 +08:00
Arvin Xu 6acba612fc feat(cli): add full API integration commands In cli (#12795)
*  feat(cli): add full API integration commands

Add comprehensive CLI commands for managing LobeHub resources:

P0 - Search, Knowledge Base, Memory:
- `lh search` - Global unified search across all resource types
- `lh kb` - Knowledge base CRUD, file management
- `lh memory` - User memory CRUD (identity/activity/context/experience/preference), persona, extraction

P1 - Agent, Session, Topic, Message:
- `lh agent` - Agent CRUD (list/view/create/edit/delete/duplicate)
- `lh session` - Session management with search
- `lh topic` - Topic CRUD with search and recent
- `lh message` - Message listing, search, delete, count, heatmap

P2 - Model, Provider:
- `lh model` - Model listing, toggle, delete per provider
- `lh provider` - Provider listing, toggle, delete

P3 - Plugin, Config:
- `lh plugin` - Plugin install/uninstall/update
- `lh whoami` - User info display
- `lh usage` - Usage statistics (monthly/daily)

Also refactors shared formatting utilities into utils/format.ts.
All commands support `--json` output for scripting.

Closes LOBE-5706, LOBE-5770

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

*  feat(cli): add file/skill commands, remove session, split kb

- Add standalone `file` command (list, view, delete, recent)
- Add `skill` command (list, view, create, edit, delete, search, import, resources)
- Remove `session` command (no longer needed)
- Remove `files` subcommand from `kb` (now separate `file` command)
- Add tests for file and skill commands
- Register new commands in index.ts

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

* 🐛 fix(cli): fix ESM require in confirm, login unhandled rejections, memory create

- Replace CommonJS require('node:readline') with ESM import in confirm helper
- Add return after process.exit(1) in login.ts to prevent unhandled rejections
- Simplify memory create to only support identity (other categories lack create procedures)

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 00:18:01 +08:00
Rylan Cai e48fd47d4e 🐛 fix: cli login and run browser in Windows (#12787)
* 🐛 fix: support authoritize in no browser environment

* wip: remove tests

* 📝 docs: remove redundant alerts

* 🐛 fix: could not invoke brower in windows

* wip: add link and unlink cli to global
2026-03-07 23:33:05 +08:00
YuTengjing b91fa68b31 🐛 fix: detect exceeded context window errors from message text (#12788) 2026-03-07 23:26:57 +08:00
LobeHub Bot ac1376ede5 🌐 chore: translate non-English comments to English in ProtocolUrlHandler (#12781) 2026-03-07 17:45:18 +08:00
YuTengjing 32b83b8c0a feat(topic): add sort by updated time option for topic sidebar (#12774) 2026-03-07 17:16:50 +08:00
Arvin Xu 2822b984f4 feat: add doc command in cli (#12752)
* add doc cli

* add doc cli

* add document command
2026-03-07 13:48:02 +08:00
lobehubbot 169d5afa93 🔖 chore(release): release version v2.1.38 [skip ci] 2026-03-06 13:43:22 +00:00
lobehubbot 42ed155944 Merge remote-tracking branch 'origin/main' into canary 2026-03-06 13:41:48 +00:00
Innei 2dc7b15c31 🚀 release: 20260306 (#12757)
This release includes **31 commits**. Key updates are below.

### New Features and Enhancements

- Added **Telegram bot access** support.
- Added **electron page tabs** functionality for desktop.
- Added **device code auth flow** for authentication.
- Added **GPT-5.4** model support.
- Show **last used auth provider** on sign-in page for better UX.
- Support **clearing hotkey bindings** in desktop ShortcutManager.
- Added **Gemini 3.1 Flash Lite Preview** model and thinkingLevel5
extend param.
- Added **auto aspect ratio and image search** support for Nano Banana
2.
- User memories now default to inject user persona instead of
identities.

### Desktop Improvements

- Unified **update channel switching** with S3 distribution.
- Added **S3 publish for canary/nightly** and S3 cleanup (keep latest
15).
- Added electron page tabs functionality.

### Stability and Fixes

- Fixed agents fork not working in community deploy.
- Fixed animation for single-line messages between reasoning and tool
calls.
- Fixed Discord bot conflict with keyPrefix.
- Fixed skew plugin issue.
- Fixed `userMemories` database failure on extra structure mismatch.
- Fixed old LobeHub plugins update issue.
- Fixed context-engine tool type recovery from manifest when models
strip suffixes.
- Added `await` to `handleResponseAPIMode` for proper error handling.
- Fixed M2M token for community agents/MCP/skill list.
- Fixed scripts to support Win32.
- Improved gateway and device gateway CI.

### Credits

Huge thanks to these contributors (alphabetical):

@arvinxx @huangkairan @Innei @LiJian @Luis-Sambrano @nekomeowww
@rdmclin2 @ReneWang @sxjeru @tjx666
2026-03-06 21:41:07 +08:00
Innei 5391ceda7d 🐛 fix(ci): add version prefix to S3 update manifest URLs (#12772)
🐛 fix(ci): target channel yml files instead of latest*.yml for version prefix

The merge-mac-files step already renames latest*.yml to {channel}*.yml
(e.g., canary-mac.yml). The previous fix targeted release/latest*.yml
which matched nothing, so the sed was a no-op.

Now targets release/${CHANNEL}*.yml directly, with latest*.yml as fallback.
2026-03-06 19:34:32 +08:00
Innei a2bf627531 🐛 fix(ci): add version prefix to latest*.yml URLs in S3 upload (#12770)
The latest*.yml files uploaded to S3 channel root lacked the $VERSION/
prefix in their URLs, causing electron-updater to request files at
the wrong path (e.g., /canary/LobeHub-Canary-xxx.zip instead of
/canary/2.1.38-canary.1/LobeHub-Canary-xxx.zip), resulting in 404.

Now sed -i modifies latest*.yml in-place before uploading, and
channel-specific yml files are copied from the already-modified ones.
2026-03-06 18:41:26 +08:00
Innei 0b7c917745 👷 build(ci): fix changelog auto-generation in release workflow (#12765)
After auto-tag-release.yml was introduced, semantic-release in release.yml
stopped working because the tag already exists when it runs. This caused
CHANGELOG.md to never be updated.

Fix: move changelog generation into auto-tag-release.yml with a custom
script that parses git log and generates gitmoji-formatted entries,
matching the existing CHANGELOG.md format. Remove the broken
semantic-release step from release.yml.
2026-03-06 17:25:44 +08:00
YuTengjing 716c27df12 🐛 fix: resolve message reordering in Responses API input conversion (#12764) 2026-03-06 17:14:26 +08:00
Innei 0dd0d11731 👷 build(ci): fix changelog auto-generation in release workflow (#12763)
After auto-tag-release.yml was introduced, semantic-release in release.yml
stopped working because the tag already exists when it runs. This caused
CHANGELOG.md to never be updated.

Fix: move changelog generation into auto-tag-release.yml with a custom
script that parses git log and generates gitmoji-formatted entries,
matching the existing CHANGELOG.md format. Remove the broken
semantic-release step from release.yml.
2026-03-06 17:08:47 +08:00
LiJian 400a0205a3 🐛 fix: when use trustclient not register market m2m token (#12762)
fix: when use trust client not take inject token
2026-03-06 17:03:34 +08:00
lobehubbot 86889b81bd 🔖 chore(release): release version v2.1.37 [skip ci] 2026-03-06 06:25:38 +00:00
Innei d3550afe05 🐛 hotfix(ci): correct stable renderer tar source path (#12755)
🐛 fix(ci): correct stable renderer tar source path

Use the current Electron renderer output directory when creating the stable renderer archive so Linux desktop release builds stop failing after packaging succeeds.

Made-with: Cursor
2026-03-06 14:24:06 +08:00
LiJian 4d240cf7fa 🐛 fix: slove the agnets fork not work in communtiy deploy (#12750)
* fix: slove the agnets fork not work in communtiy deploy

* fix: slove the secure token set & registerM2MToken not batch

* Revert "fix: slove the secure token set & registerM2MToken not batch"

This reverts commit 4485e57165.
2026-03-06 14:12:48 +08:00
YuTengjing db45907ab8 feat: add GPT-5.4 model support (#12744)
*  feat: add GPT-5.4 model support and fix reasoning payload pruning

- Add GPT-5.4 model card to model-bank
- Update planCardModels to use gpt-5.4
- Add gpt-5.4 to responsesAPIModels
- Fix pruneReasoningPayload to strip logprobs/top_logprobs for reasoning models
- Add logprobs, top_logprobs to ChatStreamPayload type
- Extend reasoning_effort to include none and xhigh
- Add success log for non-fallback requests in RouterRuntime
- Fix log parameter mismatch in RouterRuntime

Fixes LOBE-5735

* 🐛 fix: match gpt-5.4 to gpt5_2ReasoningEffort in openrouter and vercelaigateway

* 🐛 fix: update OpenRouterReasoning effort type to include none and xhigh

* 🐛 fix: use tiered pricing for gpt-5.4 based on 272K token threshold

* 🌐 chore: update i18n translations

* 🐛 fix: update claude-sonnet model version to 4-6 in planCardModels

*  feat: add GPT-5.4 Pro model support

* 🐛 fix: remove dated snapshot for gpt-5.4-pro in responsesAPIModels

* 🐛 fix: add tierBy support for cross-unit tiered pricing threshold

OpenAI charges output at 1.5x when INPUT exceeds 272K tokens.
The tiered strategy previously only checked the unit's own quantity
to select a tier. Added optional tierBy field to TieredPricingUnit
so output/cacheRead tiers can reference input quantity for selection.

* 🐛 fix: use totalInputTokens for tiered pricing tier selection

Tiered pricing tiers should be determined by total prompt size
(totalInputTokens), not each unit's own quantity. This fixes output
and cacheRead being charged at the wrong tier rate when the prompt
exceeds the threshold but the individual unit quantity does not.
2026-03-06 13:47:31 +08:00
Arvin Xu 76a07d811b feat: init lobehub-cli (#12735)
* init cli project

* Potential fix for code scanning alert no. 184: Uncontrolled command line

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* update

* Potential fix for code scanning alert no. 185: Uncontrolled command line

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-03-06 11:42:29 +08:00
LobeHub Bot 616d53e2ec 🌐 chore: translate non-English comments to English in ChatInput/ActionBar/Tools (#12663)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 11:27:27 +08:00
lobehubbot 6c1c60ee27 🔖 chore(release): release version v2.1.36 [skip ci] 2026-03-05 12:45:01 +00:00
1081 changed files with 89624 additions and 5781 deletions
+67 -14
View File
@@ -28,9 +28,11 @@ packages/agent-tracing/
recorder/
index.ts # appendStepToPartial(), finalizeSnapshot()
viewer/
index.ts # Terminal rendering: renderSnapshot, renderStepDetail, renderMessageDetail, renderSummaryTable
index.ts # Terminal rendering: renderSnapshot, renderStepDetail, renderMessageDetail, renderSummaryTable, renderPayload, renderPayloadTools, renderMemory
cli/
index.ts # CLI entry point (#!/usr/bin/env bun)
inspect.ts # Inspect command (default)
partial.ts # Partial snapshot commands (list, inspect, clean)
index.ts # Barrel exports
```
@@ -46,19 +48,16 @@ packages/agent-tracing/
All commands run from the **repo root**:
```bash
# View latest trace (tree overview)
agent-tracing trace
# View specific trace
agent-tracing trace <traceId>
# View latest trace (tree overview, `inspect` is the default command)
agent-tracing
agent-tracing inspect
agent-tracing inspect <traceId>
agent-tracing inspect latest
# List recent snapshots
agent-tracing list
agent-tracing list -l 20
# Inspect trace detail (overview)
agent-tracing inspect <traceId>
# Inspect specific step (-s is short for --step)
agent-tracing inspect <traceId> -s 0
@@ -78,30 +77,84 @@ agent-tracing inspect <traceId> -s 0 -e
# View runtime context (-c is short for --context)
agent-tracing inspect <traceId> -s 0 -c
# View context engine input overview (-p is short for --payload)
agent-tracing inspect <traceId> -p
agent-tracing inspect <traceId> -s 0 -p
# View available tools in payload (-T is short for --payload-tools)
agent-tracing inspect <traceId> -T
agent-tracing inspect <traceId> -s 0 -T
# View user memory (-M is short for --memory)
agent-tracing inspect <traceId> -M
agent-tracing inspect <traceId> -s 0 -M
# Raw JSON output (-j is short for --json)
agent-tracing inspect <traceId> -j
agent-tracing inspect <traceId> -s 0 -j
# List in-progress partial snapshots
agent-tracing partial list
# Inspect a partial (use `inspect` directly — all flags work with partial IDs)
agent-tracing inspect <partialOperationId>
agent-tracing inspect <partialOperationId> -T
agent-tracing inspect <partialOperationId> -p
# Clean up stale partial snapshots
agent-tracing partial clean
```
## Inspect Flag Reference
| Flag | Short | Description | Default Step |
| ----------------- | ----- | ------------------------------------------------------------------------------------------------- | ------------ |
| `--step <n>` | `-s` | Target a specific step | — |
| `--messages` | `-m` | Messages context (CE input → params → LLM payload) | — |
| `--tools` | `-t` | Tool calls & results (what agent invoked) | — |
| `--events` | `-e` | Raw events (llm_start, llm_result, etc.) | — |
| `--context` | `-c` | Runtime context & payload (raw) | — |
| `--system-role` | `-r` | Full system role content | 0 |
| `--env` | | Environment context | 0 |
| `--payload` | `-p` | Context engine input overview (model, knowledge, tools summary, memory summary, platform context) | 0 |
| `--payload-tools` | `-T` | Available tools detail (plugin manifests + LLM function definitions) | 0 |
| `--memory` | `-M` | Full user memory (persona, identity, contexts, preferences, experiences) | 0 |
| `--diff <n>` | `-d` | Diff against step N (use with `-r` or `--env`) | — |
| `--msg <n>` | | Full content of message N from Final LLM Payload | — |
| `--msg-input <n>` | | Full content of message N from Context Engine Input | — |
| `--json` | `-j` | Output as JSON (combinable with any flag above) | — |
Flags marked "Default Step: 0" auto-select step 0 if `--step` is not provided. All flags support `latest` or omitted traceId.
## Typical Debug Workflow
```bash
# 1. Trigger an agent operation in the dev UI
# 2. See the overview
agent-tracing trace
agent-tracing inspect
# 3. List all traces, get traceId
agent-tracing list
# 4. Inspect a specific step's messages to see what was sent to the LLM
# 4. Quick overview of what was fed into context engine
agent-tracing inspect -p
# 5. Inspect a specific step's messages to see what was sent to the LLM
agent-tracing inspect TRACE_ID -s 0 -m
# 5. Drill into a truncated message for full content
# 6. Drill into a truncated message for full content
agent-tracing inspect TRACE_ID -s 0 --msg 2
# 6. Check tool calls and results
agent-tracing inspect 1 -t TRACE_ID -s
# 7. Check available tools vs actual tool calls
agent-tracing inspect -T # available tools
agent-tracing inspect -s 1 -t # actual tool calls & results
# 8. Inspect user memory injected into the conversation
agent-tracing inspect -M
# 9. Diff system role between steps (multi-step agents)
agent-tracing inspect TRACE_ID -r -d 2
```
## Key Types
+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 > ]
```
+17
View File
@@ -21,6 +21,23 @@ And updates:
- `packages/database/src/core/migrations.json`
- `docs/development/database-schema.dbml`
## Custom Migrations (e.g. CREATE EXTENSION)
For migrations that don't involve Drizzle schema changes (e.g. enabling PostgreSQL extensions), use the `--custom` flag:
```bash
bunx drizzle-kit generate --custom --name=enable_pg_search
```
This generates an empty SQL file and properly updates `_journal.json` and snapshot. Then edit the generated SQL file to add your custom SQL:
```sql
-- Custom SQL migration file, put your code below! --
CREATE EXTENSION IF NOT EXISTS pg_search;
```
**Do NOT manually create migration files or edit `_journal.json`** — always use `drizzle-kit generate` to ensure correct journal entries and snapshots.
## Step 2: Optimize Migration SQL Filename
Rename auto-generated filename to be meaningful:
+28
View File
@@ -83,6 +83,34 @@ See `references/` for specific testing scenarios:
- **Agent Runtime E2E testing**: `references/agent-runtime-e2e.md`
- **Desktop Controller testing**: `references/desktop-controller-test.md`
## Fixing Failing Tests — Optimize or Delete?
When tests fail due to implementation changes (not bugs), evaluate before blindly fixing:
### Keep & Fix (update test data/assertions)
- **Behavior tests**: Tests that verify _what_ the code does (output, side effects, user-visible behavior). Just update mock data formats or expected values.
- Example: Tool data structure changed from `{ name }` to `{ function: { name } }` → update mock data
- Example: Output format changed from `Current date: YYYY-MM-DD` to `Current date: YYYY-MM-DD (TZ)` → update expected string
### Delete (over-specified, low value)
- **Param-forwarding tests**: Tests that assert exact internal function call arguments (e.g., `expect(internalFn).toHaveBeenCalledWith(expect.objectContaining({ exact params }))`) — these break on every refactor and duplicate what behavior tests already cover.
- **Implementation-coupled tests**: Tests that verify _how_ the code works internally rather than _what_ it produces. If a higher-level test already covers the same behavior, the low-level test adds maintenance cost without coverage gain.
### Decision Checklist
1. Does the test verify **externally observable behavior** (API response, DB write, rendered output)? → **Keep**
2. Does the test only verify **internal wiring** (which function receives which params)? → Check if a behavior test already covers it. If yes → **Delete**
3. Is the same behavior already tested at a **higher integration level**? → Delete the lower-level duplicate
4. Would the test break again on the **next routine refactor**? → Consider raising to integration level or deleting
### When Writing New Tests
- Prefer **integration-level assertions** (verify final output) over **white-box assertions** (verify internal calls)
- Use `expect.objectContaining` only for stable, public-facing contracts — not for internal param shapes that change with refactors
- Mock at boundaries (DB, network, external services), not between internal modules
## Common Issues
1. **Module pollution**: Use `vi.resetModules()` when tests fail mysteriously
+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
+10 -2
View File
@@ -63,7 +63,7 @@ Version number is automatically bumped by patch +1. There are 4 common scenarios
| Weekly Release | canary | `release/weekly-{YYYYMMDD}` | Weekly release train, canary → main |
| Bug Hotfix | main | `hotfix/v{version}-{hash}` | Emergency bug fix |
| New Model Launch | canary | Community PR merged directly | New model launch, triggered by PR title prefix |
| DB Schema Migration | canary | `release/db-migration-{name}` | Database migration, requires dedicated changelog |
| DB Schema Migration | main | `release/db-migration-{name}` | Database migration, requires dedicated changelog |
All scenarios auto-bump patch +1. Patch PR titles do not need a version number. See `reference/patch-release-scenarios.md` for detailed steps per scenario.
@@ -116,6 +116,14 @@ When the user requests a release:
3. Push and create a PR — **title must be `🚀 release: v{version}`**
4. Inform the user that merging the PR will automatically trigger the release
### Precheck
Before creating the release branch, verify the source branch:
- **Weekly Release** (`release/weekly-*`): must branch from `canary`
- **All other release/hotfix branches**: must branch from `main` — run `git merge-base --is-ancestor main <branch> && echo OK` to confirm
- If the branch is based on the wrong source, delete and recreate from the correct base
### Patch Release
Choose the appropriate workflow based on the scenario (see `reference/patch-release-scenarios.md`):
@@ -123,7 +131,7 @@ Choose the appropriate workflow based on the scenario (see `reference/patch-rele
- **Weekly Release**: Create a `release/weekly-{YYYYMMDD}` branch from canary, scan `git log main..canary` to write the changelog, title like `🚀 release: 20260222`
- **Bug Hotfix**: Create a `hotfix/` branch from main, use a gitmoji prefix title (e.g. `🐛 fix: ...`)
- **New Model Launch**: Community PRs trigger automatically via title prefix (`feat` / `style`), no extra steps needed
- **DB Migration**: Create a `release/db-migration-{name}` branch from canary, write a dedicated migration changelog
- **DB Migration**: Create a `release/db-migration-{name}` branch from main, cherry-pick migration commits, write a dedicated migration changelog
### Important Notes
@@ -15,4 +15,4 @@ This release includes a **database schema migration** involving **5 new tables**
- The migration runs automatically on application startup
- No manual intervention required
The migration owner: @arvinxx — responsible for this database schema change, reach out for any migration-related issues.
The migration owner: @\[pr-author] — responsible for this database schema change, reach out for any migration-related issues.
@@ -91,12 +91,13 @@ Database schema changes that need to be released independently. These require a
### Steps
1. **Create release branch from canary**
1. **Create release branch from main and cherry-pick migration commits**
```bash
git checkout canary
git pull origin canary
git checkout main
git pull --rebase origin main
git checkout -b release/db-migration-{name}
git cherry-pick <migration-commit-hash>
git push -u origin release/db-migration-{name}
```
@@ -83,17 +83,15 @@ runs:
fi
done
# 2. 创建 {channel}*.yml (从 latest*.yml 复制,URL 加版本目录前缀)
# electron-builder 始终生成 latest*.yml,不区分 channel
# electron-updater 在对应 channel 时会找 {channel}-mac.yml
# 2. 为所有 yml manifest 的 URL 加版本目录前缀
# merge-mac-files 步骤已生成 {channel}*.yml (如 canary-mac.yml)
# 安装包在 s3://$BUCKET/$CHANNEL/$VERSION/ 下,URL 需加 $VERSION/ 前缀
echo ""
echo "📋 Creating ${CHANNEL}*.yml files from latest*.yml..."
for yml in release/latest*.yml; do
echo "📋 Adding version prefix to yml manifest URLs..."
for yml in release/${CHANNEL}*.yml release/latest*.yml; do
if [ -f "$yml" ]; then
channel_name=$(basename "$yml" | sed "s/latest/$CHANNEL/")
# url: xxx.dmg -> url: {VERSION}/xxx.dmg
sed "s|url: |url: $VERSION/|g" "$yml" > "release/$channel_name"
echo " 📄 Created $channel_name from $(basename $yml) with URL prefix: $VERSION/"
sed -i "s|url: |url: $VERSION/|g" "$yml"
echo " 📄 Updated $(basename $yml) with URL prefix: $VERSION/"
fi
done
+37 -8
View File
@@ -72,6 +72,23 @@ jobs:
git checkout main
git pull --rebase origin main
- name: Setup Node.js
if: steps.release.outputs.should_tag == 'true' || steps.patch.outputs.should_tag == 'true'
uses: actions/setup-node@v6
with:
node-version: 24.11.1
package-manager-cache: false
- name: Install bun
if: steps.release.outputs.should_tag == 'true' || steps.patch.outputs.should_tag == 'true'
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install deps
if: steps.release.outputs.should_tag == 'true' || steps.patch.outputs.should_tag == 'true'
run: bun i
- name: Resolve patch version (patch bump)
id: patch-version
if: steps.patch.outputs.should_tag == 'true'
@@ -117,12 +134,10 @@ jobs:
echo "✅ Tag v$VERSION does not exist, can create"
fi
- name: Bump package.json version (before tagging)
- name: Bump package.json version
if: env.SHOULD_TAG == 'true' && steps.check-tag.outputs.exists == 'false'
id: bump-version
run: |
VERSION="${{ env.VERSION }}"
KIND="${{ env.KIND }}"
echo "📝 Bumping package.json version to: $VERSION"
# Validate VERSION is strict semver before writing
@@ -131,10 +146,6 @@ jobs:
exit 1
fi
# Configure git
git config --global user.name "lobehubbot"
git config --global user.email "i@lobehub.com"
# Update package.json using Node.js
node -e "
const fs = require('fs');
@@ -149,8 +160,26 @@ jobs:
console.log('✅ package.json updated to', target);
"
- name: Generate changelog
if: env.SHOULD_TAG == 'true' && steps.check-tag.outputs.exists == 'false'
run: bun run workflow:changelog:gen
- name: Build static changelog
if: env.SHOULD_TAG == 'true' && steps.check-tag.outputs.exists == 'false'
run: bun run workflow:changelog
- name: Commit release changes and push
if: env.SHOULD_TAG == 'true' && steps.check-tag.outputs.exists == 'false'
id: bump-version
run: |
VERSION="${{ env.VERSION }}"
# Configure git
git config --global user.name "lobehubbot"
git config --global user.email "i@lobehub.com"
# Commit changes (if any) and push
git add package.json
git add package.json CHANGELOG.md changelog/
COMMIT_MSG="🔖 chore(release): release version v$VERSION [skip ci]"
git commit -m "$COMMIT_MSG" || echo "Nothing to commit"
git push origin HEAD:main
+2 -1
View File
@@ -236,7 +236,8 @@ jobs:
if: runner.os == 'Linux'
run: |
npm run desktop:package:app
tar -czf apps/desktop/release/lobehub-renderer.tar.gz -C out .
test -d apps/desktop/dist/renderer
tar -czf apps/desktop/release/lobehub-renderer.tar.gz -C apps/desktop/dist/renderer .
env:
UPDATE_CHANNEL: stable
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
+3 -1
View File
@@ -122,7 +122,9 @@ jobs:
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
# 过滤掉带 v 前缀的 tag(如 lobehub/lobehub:v2.1.29),只保留无 v 前缀的版本号和 latest
TAGS=$(jq -cr '.tags | map(select(test(":v\\d") | not)) | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON")
docker buildx imagetools create $TAGS \
$(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
- name: Inspect image
-32
View File
@@ -66,38 +66,6 @@ jobs:
- name: Test App
run: bun run test-app
- name: Extract version from tag
id: get-version
run: |
# Extract version from github.ref (refs/tags/v1.0.0 -> 1.0.0)
VERSION=${GITHUB_REF#refs/tags/v}
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "📦 Release version: v$VERSION"
- name: Verify package.json version matches tag
run: |
VERSION="${{ steps.get-version.outputs.version }}"
echo "🔎 Checking package.json version equals tag: $VERSION"
node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf8'));
const expected = '$VERSION';
const actual = pkg.version;
if (actual !== expected) {
console.error('❌ Version mismatch: package.json=' + actual + ' tag=' + expected);
process.exit(1);
}
console.log('✅ Version OK:', actual);
"
- name: Release
run: bun run release
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
# Pass version to semantic-release
SEMANTIC_RELEASE_VERSION: ${{ steps.get-version.outputs.version }}
- name: Workflow
run: bun run workflow:readme
+60
View File
@@ -2,6 +2,66 @@
# Changelog
### [Version 2.1.39](https://github.com/lobehub/lobe-chat/compare/v2.1.38...v2.1.39)
<sup>Released on **2026-03-09**</sup>
#### 👷 Build System
- **misc**: add api key hash column migration.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### Build System
- **misc**: add api key hash column migration, closes [#12862](https://github.com/lobehub/lobe-chat/issues/12862) ([4e6790e](https://github.com/lobehub/lobe-chat/commit/4e6790e))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
### [Version 2.1.38](https://github.com/lobehub/lobe-chat/compare/v2.1.37-canary.4...v2.1.38)
<sup>Released on **2026-03-06**</sup>
#### 👷 Build System
- **ci**: fix changelog auto-generation in release workflow.
#### 🐛 Bug Fixes
- **misc**: when use trustclient not register market m2m token.
- **ci**: correct stable renderer tar source path.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### Build System
- **ci**: fix changelog auto-generation in release workflow, closes [#12765](https://github.com/lobehub/lobe-chat/issues/12765) ([0b7c917](https://github.com/lobehub/lobe-chat/commit/0b7c917))
#### What's fixed
- **misc**: when use trustclient not register market m2m token, closes [#12762](https://github.com/lobehub/lobe-chat/issues/12762) ([400a020](https://github.com/lobehub/lobe-chat/commit/400a020))
- **ci**: correct stable renderer tar source path, closes [#12755](https://github.com/lobehub/lobe-chat/issues/12755) ([d3550af](https://github.com/lobehub/lobe-chat/commit/d3550af))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
### [Version 2.1.26](https://github.com/lobehub/lobe-chat/compare/v2.1.25...v2.1.26)
<sup>Released on **2026-02-10**</sup>
+14
View File
@@ -0,0 +1,14 @@
lockfile=false
ignore-workspace-root-check=true
public-hoist-pattern[]=*@umijs/lint*
public-hoist-pattern[]=*unicorn*
public-hoist-pattern[]=*changelog*
public-hoist-pattern[]=*commitlint*
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*postcss*
public-hoist-pattern[]=*prettier*
public-hoist-pattern[]=*remark*
public-hoist-pattern[]=*semantic-release*
public-hoist-pattern[]=*stylelint*
+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)');
});
});
});
+45
View File
@@ -0,0 +1,45 @@
{
"name": "@lobehub/cli",
"version": "0.0.1-canary.12",
"type": "module",
"bin": {
"lh": "./dist/index.js",
"lobe": "./dist/index.js",
"lobehub": "./dist/index.js"
},
"files": [
"dist"
],
"scripts": {
"build": "npx tsup",
"cli:link": "bun link",
"cli:unlink": "bun unlink",
"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",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@trpc/client": "^11.8.1",
"commander": "^13.1.0",
"debug": "^4.4.0",
"diff": "^8.0.3",
"fast-glob": "^3.3.3",
"picocolors": "^1.1.1",
"superjson": "^2.2.6",
"ws": "^8.18.1"
},
"devDependencies": {
"@lobechat/device-gateway-client": "workspace:*",
"@lobechat/local-file-shell": "workspace:*",
"@types/node": "^22.13.5",
"@types/ws": "^8.18.1",
"tsup": "^8.4.0",
"typescript": "^5.9.3"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org"
}
}
+5
View File
@@ -0,0 +1,5 @@
packages:
- '../../packages/device-gateway-client'
- '../../packages/local-file-shell'
- '../../packages/file-loaders'
- '.'
+72
View File
@@ -0,0 +1,72 @@
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';
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;
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) {
log.error("No authentication found. Run 'lh login' first.");
process.exit(1);
}
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 },
transformer: superjson,
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;
}
+52
View File
@@ -0,0 +1,52 @@
import { getValidToken } from '../auth/refresh';
import { OFFICIAL_SERVER_URL } from '../constants/urls';
import { loadSettings } from '../settings';
import { log } from '../utils/logger';
// Must match the server's SECRET_XOR_KEY (src/envs/auth.ts)
const SECRET_XOR_KEY = 'LobeHub · LobeHub';
/**
* XOR-obfuscate a payload and encode as Base64.
* The /webapi/* routes require `X-lobe-chat-auth` with this encoding.
*/
function obfuscatePayloadWithXOR(payload: Record<string, any>): string {
const jsonString = JSON.stringify(payload);
const dataBytes = new TextEncoder().encode(jsonString);
const keyBytes = new TextEncoder().encode(SECRET_XOR_KEY);
const result = new Uint8Array(dataBytes.length);
for (let i = 0; i < dataBytes.length; i++) {
result[i] = dataBytes[i] ^ keyBytes[i % keyBytes.length];
}
return btoa(String.fromCharCode(...result));
}
export interface AuthInfo {
accessToken: string;
/** Headers required for /webapi/* endpoints (includes both X-lobe-chat-auth and Oidc-Auth) */
headers: Record<string, string>;
serverUrl: string;
}
export async function getAuthInfo(): Promise<AuthInfo> {
const result = await getValidToken();
if (!result) {
log.error("No authentication found. Run 'lh login' first.");
process.exit(1);
}
const accessToken = result!.credentials.accessToken;
const serverUrl = loadSettings()?.serverUrl || OFFICIAL_SERVER_URL;
return {
accessToken,
headers: {
'Content-Type': 'application/json',
'Oidc-Auth': accessToken,
'X-lobe-chat-auth': obfuscatePayloadWithXOR({}),
},
serverUrl: serverUrl.replace(/\/$/, ''),
};
}
+130
View File
@@ -0,0 +1,130 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
clearCredentials,
loadCredentials,
saveCredentials,
type StoredCredentials,
} from './credentials';
// Use a fixed temp path to avoid hoisting issues with vi.mock
const tmpDir = path.join(os.tmpdir(), 'lobehub-cli-test-creds');
const credentialsDir = path.join(tmpDir, '.lobehub');
const credentialsFile = path.join(credentialsDir, 'credentials.json');
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<Record<string, any>>();
return {
...actual,
default: {
...actual['default'],
homedir: () => path.join(os.tmpdir(), 'lobehub-cli-test-creds'),
},
};
});
describe('credentials', () => {
beforeEach(() => {
fs.mkdirSync(tmpDir, { recursive: true });
});
afterEach(() => {
fs.rmSync(tmpDir, { force: true, recursive: true });
});
const testCredentials: StoredCredentials = {
accessToken: 'test-access-token',
expiresAt: Math.floor(Date.now() / 1000) + 3600,
refreshToken: 'test-refresh-token',
};
describe('saveCredentials + loadCredentials', () => {
it('should save and load credentials successfully', () => {
saveCredentials(testCredentials);
const loaded = loadCredentials();
expect(loaded).toEqual(testCredentials);
});
it('should create directory with correct permissions', () => {
saveCredentials(testCredentials);
expect(fs.existsSync(credentialsDir)).toBe(true);
});
it('should encrypt the credentials file', () => {
saveCredentials(testCredentials);
const raw = fs.readFileSync(credentialsFile, 'utf8');
// Should not be plain JSON
expect(() => JSON.parse(raw)).toThrow();
// Should be base64
expect(Buffer.from(raw, 'base64').length).toBeGreaterThan(0);
});
it('should handle credentials without optional fields', () => {
const minimal: StoredCredentials = {
accessToken: 'tok',
};
saveCredentials(minimal);
const loaded = loadCredentials();
expect(loaded).toEqual(minimal);
});
});
describe('loadCredentials', () => {
it('should return null when no credentials file exists', () => {
const result = loadCredentials();
expect(result).toBeNull();
});
it('should handle legacy plaintext JSON and re-encrypt', () => {
fs.mkdirSync(credentialsDir, { recursive: true });
fs.writeFileSync(credentialsFile, JSON.stringify(testCredentials));
const loaded = loadCredentials();
expect(loaded).toEqual(testCredentials);
// Should have been re-encrypted
const raw = fs.readFileSync(credentialsFile, 'utf8');
expect(() => JSON.parse(raw)).toThrow();
});
it('should return null for corrupted file', () => {
fs.mkdirSync(credentialsDir, { recursive: true });
fs.writeFileSync(credentialsFile, 'not-valid-base64-or-json!!!');
const result = loadCredentials();
expect(result).toBeNull();
});
});
describe('clearCredentials', () => {
it('should remove credentials file and return true', () => {
saveCredentials(testCredentials);
const result = clearCredentials();
expect(result).toBe(true);
expect(fs.existsSync(credentialsFile)).toBe(false);
});
it('should return false when no file exists', () => {
const result = clearCredentials();
expect(result).toBe(false);
});
});
});
+77
View File
@@ -0,0 +1,77 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
export interface StoredCredentials {
accessToken: string;
expiresAt?: number; // Unix timestamp (seconds)
refreshToken?: string;
}
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
// Not bulletproof, but prevents casual reading of the credentials file
function deriveKey(): Buffer {
const material = `lobehub-cli:${os.hostname()}:${os.userInfo().username}`;
return crypto.pbkdf2Sync(material, 'lobehub-cli-salt', 100_000, 32, 'sha256');
}
function encrypt(plaintext: string): string {
const key = deriveKey();
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
// Pack: iv(12) + authTag(16) + ciphertext
const packed = Buffer.concat([iv, authTag, encrypted]);
return packed.toString('base64');
}
function decrypt(encoded: string): string {
const key = deriveKey();
const packed = Buffer.from(encoded, 'base64');
const iv = packed.subarray(0, 12);
const authTag = packed.subarray(12, 28);
const ciphertext = packed.subarray(28);
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(authTag);
return decipher.update(ciphertext) + decipher.final('utf8');
}
export function saveCredentials(credentials: StoredCredentials): void {
fs.mkdirSync(CREDENTIALS_DIR, { mode: 0o700, recursive: true });
const encrypted = encrypt(JSON.stringify(credentials));
fs.writeFileSync(CREDENTIALS_FILE, encrypted, { mode: 0o600 });
}
export function loadCredentials(): StoredCredentials | null {
try {
const data = fs.readFileSync(CREDENTIALS_FILE, 'utf8');
// Try decrypting first
try {
const decrypted = decrypt(data);
return JSON.parse(decrypted) as StoredCredentials;
} catch {
// Fallback: handle legacy plaintext JSON, re-save encrypted
const credentials = JSON.parse(data) as StoredCredentials;
saveCredentials(credentials);
return credentials;
}
} catch {
return null;
}
}
export function clearCredentials(): boolean {
try {
fs.unlinkSync(CREDENTIALS_FILE);
return true;
} catch {
return false;
}
}
+224
View File
@@ -0,0 +1,224 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { loadSettings } from '../settings';
import type { StoredCredentials } from './credentials';
import { loadCredentials, saveCredentials } from './credentials';
import { getValidToken } from './refresh';
vi.mock('./credentials', () => ({
loadCredentials: vi.fn(),
saveCredentials: vi.fn(),
}));
vi.mock('../settings', () => ({
loadSettings: vi.fn().mockReturnValue({ serverUrl: 'https://app.lobehub.com' }),
}));
describe('getValidToken', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn());
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should return null when no credentials stored', async () => {
vi.mocked(loadCredentials).mockReturnValue(null);
const result = await getValidToken();
expect(result).toBeNull();
});
it('should return credentials when token is still valid', async () => {
const creds: StoredCredentials = {
accessToken: 'valid-token',
expiresAt: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now
refreshToken: 'refresh-tok',
};
vi.mocked(loadCredentials).mockReturnValue(creds);
const result = await getValidToken();
expect(result).toEqual({ credentials: creds });
expect(fetch).not.toHaveBeenCalled();
});
it('should return credentials when no expiresAt is set', async () => {
const creds: StoredCredentials = {
accessToken: 'valid-token',
};
vi.mocked(loadCredentials).mockReturnValue(creds);
const result = await getValidToken();
// expiresAt is undefined, so Date.now()/1000 < undefined - 60 is false (NaN comparison)
// This means it will try to refresh, but there's no refreshToken
expect(result).toBeNull();
});
it('should return null when token expired and no refresh token', async () => {
const creds: StoredCredentials = {
accessToken: 'expired-token',
expiresAt: Math.floor(Date.now() / 1000) - 100, // expired
};
vi.mocked(loadCredentials).mockReturnValue(creds);
const result = await getValidToken();
expect(result).toBeNull();
});
it('should refresh and save updated credentials when token is expired', async () => {
const creds: StoredCredentials = {
accessToken: 'expired-token',
expiresAt: Math.floor(Date.now() / 1000) - 100,
refreshToken: 'valid-refresh-token',
};
vi.mocked(loadCredentials).mockReturnValue(creds);
vi.mocked(fetch).mockResolvedValue({
json: vi.fn().mockResolvedValue({
access_token: 'new-access-token',
expires_in: 3600,
refresh_token: 'new-refresh-token',
token_type: 'Bearer',
}),
ok: true,
} as any);
const result = await getValidToken();
expect(result).not.toBeNull();
expect(result!.credentials.accessToken).toBe('new-access-token');
expect(result!.credentials.refreshToken).toBe('new-refresh-token');
expect(saveCredentials).toHaveBeenCalledWith(
expect.objectContaining({ accessToken: 'new-access-token' }),
);
});
it('should keep old refresh token if new one is not returned', async () => {
const creds: StoredCredentials = {
accessToken: 'expired-token',
expiresAt: Math.floor(Date.now() / 1000) - 100,
refreshToken: 'old-refresh-token',
};
vi.mocked(loadCredentials).mockReturnValue(creds);
vi.mocked(fetch).mockResolvedValue({
json: vi.fn().mockResolvedValue({
access_token: 'new-access-token',
token_type: 'Bearer',
}),
ok: true,
} as any);
const result = await getValidToken();
expect(result!.credentials.refreshToken).toBe('old-refresh-token');
expect(result!.credentials.expiresAt).toBeUndefined();
});
it('should return null when refresh request fails (non-ok)', async () => {
const creds: StoredCredentials = {
accessToken: 'expired-token',
expiresAt: Math.floor(Date.now() / 1000) - 100,
refreshToken: 'valid-refresh-token',
};
vi.mocked(loadCredentials).mockReturnValue(creds);
vi.mocked(fetch).mockResolvedValue({
json: vi.fn().mockResolvedValue({}),
ok: false,
status: 401,
} as any);
const result = await getValidToken();
expect(result).toBeNull();
});
it('should return null when refresh response has error field', async () => {
const creds: StoredCredentials = {
accessToken: 'expired-token',
expiresAt: Math.floor(Date.now() / 1000) - 100,
refreshToken: 'valid-refresh-token',
};
vi.mocked(loadCredentials).mockReturnValue(creds);
vi.mocked(fetch).mockResolvedValue({
json: vi.fn().mockResolvedValue({ error: 'invalid_grant' }),
ok: true,
} as any);
const result = await getValidToken();
expect(result).toBeNull();
});
it('should return null when refresh response has no access_token', async () => {
const creds: StoredCredentials = {
accessToken: 'expired-token',
expiresAt: Math.floor(Date.now() / 1000) - 100,
refreshToken: 'valid-refresh-token',
};
vi.mocked(loadCredentials).mockReturnValue(creds);
vi.mocked(fetch).mockResolvedValue({
json: vi.fn().mockResolvedValue({ token_type: 'Bearer' }),
ok: true,
} as any);
const result = await getValidToken();
expect(result).toBeNull();
});
it('should return null when network error occurs during refresh', async () => {
const creds: StoredCredentials = {
accessToken: 'expired-token',
expiresAt: Math.floor(Date.now() / 1000) - 100,
refreshToken: 'valid-refresh-token',
};
vi.mocked(loadCredentials).mockReturnValue(creds);
vi.mocked(fetch).mockRejectedValue(new Error('network error'));
const result = await getValidToken();
expect(result).toBeNull();
});
it('should send correct request to refresh endpoint', async () => {
const creds: StoredCredentials = {
accessToken: 'expired-token',
expiresAt: Math.floor(Date.now() / 1000) - 100,
refreshToken: 'my-refresh-token',
};
vi.mocked(loadCredentials).mockReturnValue(creds);
vi.mocked(loadSettings).mockReturnValueOnce({ serverUrl: 'https://my-server.com' });
vi.mocked(fetch).mockResolvedValue({
json: vi.fn().mockResolvedValue({
access_token: 'new-token',
token_type: 'Bearer',
}),
ok: true,
} as any);
await getValidToken();
expect(fetch).toHaveBeenCalledWith(
'https://my-server.com/oidc/token',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
}),
);
const body = vi.mocked(fetch).mock.calls[0][1]?.body as URLSearchParams;
expect(body.get('grant_type')).toBe('refresh_token');
expect(body.get('refresh_token')).toBe('my-refresh-token');
expect(body.get('client_id')).toBe('lobehub-cli');
});
});
+69
View File
@@ -0,0 +1,69 @@
import { OFFICIAL_SERVER_URL } from '../constants/urls';
import { loadSettings } from '../settings';
import { loadCredentials, saveCredentials, type StoredCredentials } from './credentials';
const CLIENT_ID = 'lobehub-cli';
/**
* Get a valid access token, refreshing if expired.
* Returns null if no credentials or refresh fails.
*/
export async function getValidToken(): Promise<{ credentials: StoredCredentials } | null> {
const credentials = loadCredentials();
if (!credentials) return null;
// Check if token is still valid (with 60s buffer)
if (credentials.expiresAt && Date.now() / 1000 < credentials.expiresAt - 60) {
return { credentials };
}
// Token expired — try refresh
if (!credentials.refreshToken) return null;
const serverUrl = loadSettings()?.serverUrl || OFFICIAL_SERVER_URL;
const refreshed = await refreshAccessToken(serverUrl, credentials.refreshToken);
if (!refreshed) return null;
const updated: StoredCredentials = {
accessToken: refreshed.access_token,
expiresAt: refreshed.expires_in
? Math.floor(Date.now() / 1000) + refreshed.expires_in
: undefined,
refreshToken: refreshed.refresh_token || credentials.refreshToken,
};
saveCredentials(updated);
return { credentials: updated };
}
interface TokenResponse {
access_token: string;
expires_in?: number;
refresh_token?: string;
token_type: string;
}
async function refreshAccessToken(
serverUrl: string,
refreshToken: string,
): Promise<TokenResponse | null> {
try {
const res = await fetch(`${serverUrl}/oidc/token`, {
body: new URLSearchParams({
client_id: CLIENT_ID,
grant_type: 'refresh_token',
refresh_token: refreshToken,
}),
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
method: 'POST',
});
const body = (await res.json()) as TokenResponse & { error?: string };
if (!res.ok || body.error || !body.access_token) return null;
return body;
} catch {
return null;
}
}
+115
View File
@@ -0,0 +1,115 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { getValidToken } from './refresh';
import { resolveToken } from './resolveToken';
vi.mock('./refresh', () => ({
getValidToken: vi.fn(),
}));
vi.mock('../utils/logger', () => ({
log: {
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
},
}));
// Helper to create a valid JWT with sub claim
function makeJwt(sub: string): string {
const header = Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url');
const payload = Buffer.from(JSON.stringify({ sub })).toString('base64url');
return `${header}.${payload}.signature`;
}
describe('resolveToken', () => {
let exitSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit');
});
});
afterEach(() => {
exitSpy.mockRestore();
});
describe('with explicit --token', () => {
it('should return token and userId from JWT', async () => {
const token = makeJwt('user-123');
const result = await resolveToken({ token });
expect(result).toEqual({ token, userId: 'user-123' });
});
it('should exit if JWT has no sub claim', async () => {
const header = Buffer.from('{}').toString('base64url');
const payload = Buffer.from('{}').toString('base64url');
const token = `${header}.${payload}.sig`;
await expect(resolveToken({ token })).rejects.toThrow('process.exit');
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should exit if JWT is malformed', async () => {
await expect(resolveToken({ token: 'not-a-jwt' })).rejects.toThrow('process.exit');
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
describe('with --service-token', () => {
it('should return token and userId', async () => {
const result = await resolveToken({
serviceToken: 'svc-token',
userId: 'user-456',
});
expect(result).toEqual({ token: 'svc-token', userId: 'user-456' });
});
it('should exit if --user-id is not provided', async () => {
await expect(resolveToken({ serviceToken: 'svc-token' })).rejects.toThrow('process.exit');
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
describe('with stored credentials', () => {
it('should return stored credentials token', async () => {
const token = makeJwt('stored-user');
vi.mocked(getValidToken).mockResolvedValue({
credentials: {
accessToken: token,
},
});
const result = await resolveToken({});
expect(result).toEqual({ token, userId: 'stored-user' });
});
it('should exit if stored token has no sub', async () => {
const header = Buffer.from('{}').toString('base64url');
const payload = Buffer.from('{}').toString('base64url');
const token = `${header}.${payload}.sig`;
vi.mocked(getValidToken).mockResolvedValue({
credentials: {
accessToken: token,
},
});
await expect(resolveToken({})).rejects.toThrow('process.exit');
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should exit when no stored credentials', async () => {
vi.mocked(getValidToken).mockResolvedValue(null);
await expect(resolveToken({})).rejects.toThrow('process.exit');
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
});
+77
View File
@@ -0,0 +1,77 @@
import { log } from '../utils/logger';
import { getValidToken } from './refresh';
interface ResolveTokenOptions {
serviceToken?: string;
token?: string;
userId?: string;
}
interface ResolvedAuth {
token: string;
userId: string;
}
/**
* Parse the `sub` claim from a JWT without verifying the signature.
*/
function parseJwtSub(token: string): string | undefined {
try {
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString());
return payload.sub;
} catch {
return undefined;
}
}
/**
* Resolve an access token from explicit options or stored credentials.
* 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);
if (!userId) {
log.error('Could not extract userId from token. Provide --user-id explicitly.');
process.exit(1);
}
return { token: options.token, userId };
}
if (options.serviceToken) {
if (!options.userId) {
log.error('--user-id is required when using --service-token');
process.exit(1);
}
return { token: options.serviceToken, userId: options.userId };
}
// Try stored credentials
const result = await getValidToken();
if (result) {
log.debug('Using stored credentials');
const token = result.credentials.accessToken;
const userId = parseJwtSub(token);
if (!userId) {
log.error("Stored token is invalid. Run 'lh login' again.");
process.exit(1);
}
return { token, userId };
}
log.error("No authentication found. Run 'lh login' first, or provide --token.");
process.exit(1);
}
+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)}`,
);
},
);
}
+605
View File
@@ -0,0 +1,605 @@
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { log } from '../utils/logger';
import { registerAgentCommand } from './agent';
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() },
getOperationStatus: { query: vi.fn() },
},
},
}));
const { getTrpcClient: mockGetTrpcClient } = vi.hoisted(() => ({
getTrpcClient: vi.fn(),
}));
const { mockStreamAgentEvents } = vi.hoisted(() => ({
mockStreamAgentEvents: vi.fn(),
}));
const { mockGetAuthInfo } = vi.hoisted(() => ({
mockGetAuthInfo: vi.fn(),
}));
vi.mock('../api/client', () => ({ getTrpcClient: mockGetTrpcClient }));
vi.mock('../api/http', () => ({ getAuthInfo: mockGetAuthInfo }));
vi.mock('../utils/agentStream', () => ({ streamAgentEvents: mockStreamAgentEvents }));
vi.mock('../utils/logger', () => ({
log: { debug: vi.fn(), error: vi.fn(), heartbeat: vi.fn(), info: vi.fn(), warn: vi.fn() },
setVerbose: vi.fn(),
}));
describe('agent 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);
mockGetAuthInfo.mockResolvedValue({
accessToken: 'test-token',
headers: { 'Content-Type': 'application/json', 'Oidc-Auth': 'test-token' },
serverUrl: 'https://example.com',
});
mockStreamAgentEvents.mockResolvedValue(undefined);
for (const method of Object.values(mockTrpcClient.agent)) {
for (const fn of Object.values(method)) {
(fn as ReturnType<typeof vi.fn>).mockReset();
}
}
for (const method of Object.values(mockTrpcClient.aiAgent)) {
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();
registerAgentCommand(program);
return program;
}
describe('list', () => {
it('should display agents in table format', async () => {
mockTrpcClient.agent.queryAgents.query.mockResolvedValue([
{ id: 'a1', model: 'gpt-4', title: 'My Agent' },
]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'agent', 'list']);
expect(consoleSpy).toHaveBeenCalledTimes(2); // header + row
});
it('should filter by keyword', async () => {
mockTrpcClient.agent.queryAgents.query.mockResolvedValue([]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'agent', 'list', '-k', 'test']);
expect(mockTrpcClient.agent.queryAgents.query).toHaveBeenCalledWith(
expect.objectContaining({ keyword: 'test' }),
);
});
it('should output JSON', async () => {
const agents = [{ id: 'a1', title: 'Test' }];
mockTrpcClient.agent.queryAgents.query.mockResolvedValue(agents);
const program = createProgram();
await program.parseAsync(['node', 'test', 'agent', 'list', '--json']);
expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(agents, null, 2));
});
});
describe('view', () => {
it('should display agent config', async () => {
mockTrpcClient.agent.getAgentConfigById.query.mockResolvedValue({
model: 'gpt-4',
systemRole: 'You are helpful.',
title: 'Test Agent',
});
const program = createProgram();
await program.parseAsync(['node', 'test', 'agent', 'view', 'a1']);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Test Agent'));
});
it('should exit when not found', async () => {
mockTrpcClient.agent.getAgentConfigById.query.mockResolvedValue(null);
const program = createProgram();
await program.parseAsync(['node', 'test', 'agent', 'view', 'nonexistent']);
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', () => {
it('should create an agent', async () => {
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({
agentId: 'a-new',
sessionId: 's1',
});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'agent',
'create',
'--title',
'My Agent',
'--model',
'gpt-4',
]);
expect(mockTrpcClient.agent.createAgent.mutate).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({ model: 'gpt-4', title: 'My Agent' }),
}),
);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('a-new'));
});
});
describe('edit', () => {
it('should update agent config', async () => {
mockTrpcClient.agent.updateAgentConfig.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync(['node', 'test', 'agent', 'edit', 'a1', '--title', 'Updated']);
expect(mockTrpcClient.agent.updateAgentConfig.mutate).toHaveBeenCalledWith({
agentId: 'a1',
value: { title: 'Updated' },
});
});
it('should exit when no changes specified', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'agent', 'edit', 'a1']);
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', () => {
it('should delete with --yes', async () => {
mockTrpcClient.agent.removeAgent.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync(['node', 'test', 'agent', 'delete', 'a1', '--yes']);
expect(mockTrpcClient.agent.removeAgent.mutate).toHaveBeenCalledWith({ agentId: 'a1' });
});
});
describe('duplicate', () => {
it('should duplicate an agent', async () => {
mockTrpcClient.agent.duplicateAgent.mutate.mockResolvedValue({ agentId: 'a-dup' });
const program = createProgram();
await program.parseAsync(['node', 'test', 'agent', 'duplicate', 'a1', '--title', 'Copy']);
expect(mockTrpcClient.agent.duplicateAgent.mutate).toHaveBeenCalledWith(
expect.objectContaining({ agentId: 'a1', newTitle: 'Copy' }),
);
});
});
describe('run', () => {
it('should exec agent and connect to SSE stream', async () => {
mockTrpcClient.aiAgent.execAgent.mutate.mockResolvedValue({
operationId: 'op-123',
success: true,
topicId: 'topic-1',
});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'agent',
'run',
'--agent-id',
'a1',
'--prompt',
'Hello',
]);
expect(mockTrpcClient.aiAgent.execAgent.mutate).toHaveBeenCalledWith(
expect.objectContaining({ agentId: 'a1', prompt: 'Hello' }),
);
expect(mockStreamAgentEvents).toHaveBeenCalledWith(
'https://example.com/api/agent/stream?operationId=op-123',
expect.objectContaining({ 'Oidc-Auth': 'test-token' }),
expect.objectContaining({ json: undefined, verbose: undefined }),
);
});
it('should support --slug option', async () => {
mockTrpcClient.aiAgent.execAgent.mutate.mockResolvedValue({
operationId: 'op-456',
success: true,
});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'agent',
'run',
'--slug',
'my-agent',
'--prompt',
'Do something',
]);
expect(mockTrpcClient.aiAgent.execAgent.mutate).toHaveBeenCalledWith(
expect.objectContaining({ slug: 'my-agent', prompt: 'Do something' }),
);
});
it('should exit when neither --agent-id nor --slug provided', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'agent', 'run', '--prompt', 'Hello']);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('--agent-id or --slug'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should exit when --prompt not provided', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'agent', 'run', '--agent-id', 'a1']);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('--prompt'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should exit when exec fails', async () => {
mockTrpcClient.aiAgent.execAgent.mutate.mockResolvedValue({
error: 'Agent not found',
success: false,
});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'agent',
'run',
'--agent-id',
'bad',
'--prompt',
'Hi',
]);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Agent not found'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should pass --topic-id as appContext', async () => {
mockTrpcClient.aiAgent.execAgent.mutate.mockResolvedValue({
operationId: 'op-789',
success: true,
});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'agent',
'run',
'--agent-id',
'a1',
'--prompt',
'Hi',
'--topic-id',
't1',
]);
expect(mockTrpcClient.aiAgent.execAgent.mutate).toHaveBeenCalledWith(
expect.objectContaining({ appContext: { topicId: 't1' } }),
);
});
it('should pass --json to stream options', async () => {
mockTrpcClient.aiAgent.execAgent.mutate.mockResolvedValue({
operationId: 'op-j',
success: true,
});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'agent',
'run',
'--agent-id',
'a1',
'--prompt',
'Hi',
'--json',
]);
expect(mockStreamAgentEvents).toHaveBeenCalledWith(
expect.any(String),
expect.any(Object),
expect.objectContaining({ json: true }),
);
});
});
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({
cost: { total: 0.0042 },
status: 'completed',
stepCount: 3,
usage: { total_tokens: 1500 },
});
const program = createProgram();
await program.parseAsync(['node', 'test', 'agent', 'status', 'op-123']);
expect(mockTrpcClient.aiAgent.getOperationStatus.query).toHaveBeenCalledWith(
expect.objectContaining({ operationId: 'op-123' }),
);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Operation Status'));
});
it('should output JSON', async () => {
const data = { status: 'completed', stepCount: 2 };
mockTrpcClient.aiAgent.getOperationStatus.query.mockResolvedValue(data);
const program = createProgram();
await program.parseAsync(['node', 'test', 'agent', 'status', 'op-123', '--json']);
expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(data, null, 2));
});
it('should pass --history flag', async () => {
mockTrpcClient.aiAgent.getOperationStatus.query.mockResolvedValue({ status: 'running' });
const program = createProgram();
await program.parseAsync(['node', 'test', 'agent', 'status', 'op-123', '--history']);
expect(mockTrpcClient.aiAgent.getOperationStatus.query).toHaveBeenCalledWith(
expect.objectContaining({ includeHistory: true }),
);
});
});
});
+577
View File
@@ -0,0 +1,577 @@
import { readFileSync } from 'node:fs';
import type { Command } from 'commander';
import pc from 'picocolors';
import { getTrpcClient } from '../api/client';
import { getAuthInfo } from '../api/http';
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');
// ── list ──────────────────────────────────────────────
agent
.command('list')
.description('List agents')
.option('-L, --limit <n>', 'Maximum number of items', '30')
.option('-k, --keyword <keyword>', 'Filter by keyword')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(async (options: { json?: string | boolean; keyword?: string; limit?: string }) => {
const client = await getTrpcClient();
const input: { keyword?: string; limit?: number; offset?: number } = {};
if (options.keyword) input.keyword = options.keyword;
if (options.limit) input.limit = Number.parseInt(options.limit, 10);
const result = await client.agent.queryAgents.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 agents found.');
return;
}
const rows = items.map((a: any) => [
a.id || a.agentId || '',
truncate(a.title || a.name || a.meta?.title || 'Untitled', 40),
truncate(a.description || a.meta?.description || '', 50),
a.model || '',
]);
printTable(rows, ['ID', 'TITLE', 'DESCRIPTION', 'MODEL']);
});
// ── view ──────────────────────────────────────────────
agent
.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 (
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 (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(' · ')));
if (r.systemRole) {
console.log();
console.log(pc.bold('System Role:'));
console.log(r.systemRole);
}
},
);
// ── create ────────────────────────────────────────────
agent
.command('create')
.description('Create a new agent')
.option('-t, --title <title>', 'Agent title')
.option('-d, --description <desc>', 'Agent description')
.option('-m, --model <model>', 'Model ID')
.option('-p, --provider <provider>', 'Provider ID')
.option('-s, --system-role <role>', 'System role prompt')
.option('--group <groupId>', 'Group ID')
.action(
async (options: {
description?: string;
group?: string;
model?: string;
provider?: string;
systemRole?: string;
title?: string;
}) => {
const client = await getTrpcClient();
const config: Record<string, any> = {};
if (options.title) config.title = options.title;
if (options.description) config.description = options.description;
if (options.model) config.model = options.model;
if (options.provider) config.provider = options.provider;
if (options.systemRole) config.systemRole = options.systemRole;
const input: Record<string, any> = { config };
if (options.group) input.groupId = options.group;
const result = await client.agent.createAgent.mutate(input as any);
const r = result as any;
console.log(`${pc.green('✓')} Created agent ${pc.bold(r.agentId || r.id)}`);
if (r.sessionId) console.log(` Session: ${r.sessionId}`);
},
);
// ── edit ──────────────────────────────────────────────
agent
.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')
.option('-p, --provider <provider>', 'New provider ID')
.option('-s, --system-role <role>', 'New system role prompt')
.action(
async (
agentIdArg: string | undefined,
options: {
description?: string;
model?: string;
provider?: string;
slug?: string;
systemRole?: string;
title?: string;
},
) => {
const value: Record<string, any> = {};
if (options.title) value.title = options.title;
if (options.description) value.description = options.description;
if (options.model) value.model = options.model;
if (options.provider) value.provider = options.provider;
if (options.systemRole) value.systemRole = options.systemRole;
if (Object.keys(value).length === 0) {
log.error(
'No changes specified. Use --title, --description, --model, --provider, or --system-role.',
);
process.exit(1);
}
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)}`);
},
);
// ── delete ────────────────────────────────────────────
agent
.command('delete <agentId>')
.description('Delete an agent')
.option('--yes', 'Skip confirmation prompt')
.action(async (agentId: string, options: { yes?: boolean }) => {
if (!options.yes) {
const confirmed = await confirm('Are you sure you want to delete this agent?');
if (!confirmed) {
console.log('Cancelled.');
return;
}
}
const client = await getTrpcClient();
await client.agent.removeAgent.mutate({ agentId });
console.log(`${pc.green('✓')} Deleted agent ${pc.bold(agentId)}`);
});
// ── duplicate ─────────────────────────────────────────
agent
.command('duplicate <agentId>')
.description('Duplicate an agent')
.option('-t, --title <title>', 'Title for the duplicate')
.action(async (agentId: string, options: { title?: string }) => {
const client = await getTrpcClient();
const input: Record<string, any> = { agentId };
if (options.title) input.newTitle = options.title;
const result = await client.agent.duplicateAgent.mutate(input as any);
const r = result as any;
console.log(`${pc.green('✓')} Duplicated agent → ${pc.bold(r.agentId || r.id || 'done')}`);
});
// ── run ──────────────────────────────────────────────
agent
.command('run')
.description('Run an agent with a prompt')
.option('-a, --agent-id <id>', 'Agent ID')
.option('-s, --slug <slug>', 'Agent slug')
.option('-p, --prompt <text>', 'User prompt')
.option('-t, --topic-id <id>', 'Reuse an existing topic')
.option('--no-auto-start', 'Do not auto-start the agent')
.option('--json', 'Output full JSON event stream')
.option('-v, --verbose', 'Show detailed tool call info')
.option('--replay <file>', 'Replay events from a saved JSON file (offline)')
.action(
async (options: {
agentId?: string;
autoStart?: boolean;
json?: boolean;
prompt?: string;
replay?: string;
slug?: string;
topicId?: string;
verbose?: boolean;
}) => {
if (options.verbose) setVerbose(true);
// Replay mode: render from saved JSON file, no network needed
if (options.replay) {
const data = readFileSync(options.replay, 'utf8');
const events = JSON.parse(data);
replayAgentEvents(events, { json: options.json, verbose: options.verbose });
return;
}
if (!options.agentId && !options.slug) {
log.error('Either --agent-id or --slug is required.');
process.exit(1);
return;
}
if (!options.prompt) {
log.error('--prompt is required.');
process.exit(1);
return;
}
const client = await getTrpcClient();
// 1. Exec agent to get operationId
const input: Record<string, any> = { prompt: options.prompt };
if (options.agentId) input.agentId = options.agentId;
if (options.slug) input.slug = options.slug;
if (options.topicId) input.appContext = { topicId: options.topicId };
if (options.autoStart === false) input.autoStart = false;
const result = await client.aiAgent.execAgent.mutate(input as any);
const r = result as any;
if (!r.success) {
log.error(`Failed to start agent: ${r.error || r.message || 'Unknown error'}`);
process.exit(1);
}
const operationId = r.operationId;
if (!options.json) {
log.info(`Operation: ${pc.dim(operationId)} · Topic: ${pc.dim(r.topicId || 'n/a')}`);
}
// 2. Connect to SSE stream
const { serverUrl, headers } = await getAuthInfo();
const streamUrl = `${serverUrl}/api/agent/stream?operationId=${encodeURIComponent(operationId)}`;
await streamAgentEvents(streamUrl, headers, {
json: options.json,
verbose: options.verbose,
});
},
);
// ── 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
.command('status <operationId>')
.description('Check agent operation status')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.option('--history', 'Include step history')
.option('--history-limit <n>', 'Number of history entries', '10')
.action(
async (
operationId: string,
options: { history?: boolean; historyLimit?: string; json?: string | boolean },
) => {
const client = await getTrpcClient();
const input: Record<string, any> = { operationId };
if (options.history) input.includeHistory = true;
if (options.historyLimit) input.historyLimit = Number.parseInt(options.historyLimit, 10);
const result = await client.aiAgent.getOperationStatus.query(input as any);
const r = result as any;
if (options.json !== undefined) {
const fields = typeof options.json === 'string' ? options.json : undefined;
outputJson(r, fields);
return;
}
console.log(pc.bold('Operation Status'));
console.log(` ID: ${operationId}`);
console.log(` Status: ${colorStatus(r.status || r.state || 'unknown')}`);
if (r.stepCount !== undefined) console.log(` Steps: ${r.stepCount}`);
if (r.usage?.total_tokens) console.log(` Tokens: ${r.usage.total_tokens}`);
if (r.cost?.total !== undefined) console.log(` Cost: $${r.cost.total.toFixed(4)}`);
if (r.error) console.log(` Error: ${pc.red(r.error)}`);
if (r.createdAt) console.log(` Started: ${r.createdAt}`);
if (r.completedAt) console.log(` Ended: ${r.completedAt}`);
},
);
}
function colorStatus(status: string): string {
switch (status) {
case 'completed':
case 'success': {
return pc.green(status);
}
case 'failed':
case 'error': {
return pc.red(status);
}
case 'processing':
case 'running': {
return pc.yellow(status);
}
default: {
return pc.dim(status);
}
}
}
+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}`);
}
});
}
+129
View File
@@ -0,0 +1,129 @@
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { registerConfigCommand } from './config';
const { mockTrpcClient } = vi.hoisted(() => ({
mockTrpcClient: {
usage: {
findAndGroupByDateRange: { query: vi.fn() },
findAndGroupByDay: { query: vi.fn() },
findByMonth: { query: vi.fn() },
},
user: {
getUserState: { query: 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('config command', () => {
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
mockGetTrpcClient.mockResolvedValue(mockTrpcClient);
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(() => {
consoleSpy.mockRestore();
});
function createProgram() {
const program = new Command();
program.exitOverride();
registerConfigCommand(program);
return program;
}
describe('whoami', () => {
it('should display user info', async () => {
mockTrpcClient.user.getUserState.query.mockResolvedValue({
email: 'test@example.com',
fullName: 'Test User',
userId: 'u1',
username: 'testuser',
});
const program = createProgram();
await program.parseAsync(['node', 'test', 'whoami']);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Test User'));
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('testuser'));
});
it('should output JSON', async () => {
const state = { email: 'test@example.com', userId: 'u1' };
mockTrpcClient.user.getUserState.query.mockResolvedValue(state);
const program = createProgram();
await program.parseAsync(['node', 'test', 'whoami', '--json']);
expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(state, null, 2));
});
});
describe('usage', () => {
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.findAndGroupByDay.query).toHaveBeenCalled();
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('2024-01-15'));
});
it('should pass month param', async () => {
mockTrpcClient.usage.findAndGroupByDay.query.mockResolvedValue([]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'usage', '--month', '2024-01']);
expect(mockTrpcClient.usage.findAndGroupByDay.query).toHaveBeenCalledWith({ mo: '2024-01' });
});
it('should output JSON with --json flag', async () => {
const data = { totalTokens: 1000 };
mockTrpcClient.usage.findByMonth.query.mockResolvedValue(data);
const program = createProgram();
await program.parseAsync(['node', 'test', 'usage', '--json']);
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));
});
});
});
+196
View File
@@ -0,0 +1,196 @@
import type { Command } from 'commander';
import pc from 'picocolors';
import { getTrpcClient } from '../api/client';
import {
type BoxTableRow,
formatCost,
formatNumber,
outputJson,
printBoxTable,
printCalendarHeatmap,
} from '../utils/format';
export function registerConfigCommand(program: Command) {
// ── whoami ────────────────────────────────────────────
program
.command('whoami')
.description('Display current user information')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(async (options: { json?: string | boolean }) => {
const client = await getTrpcClient();
const state = await client.user.getUserState.query();
if (options.json !== undefined) {
const fields = typeof options.json === 'string' ? options.json : undefined;
outputJson(state, fields);
return;
}
const s = state as any;
console.log(pc.bold('User Info'));
if (s.fullName || s.firstName) console.log(` Name: ${s.fullName || s.firstName}`);
if (s.username) console.log(` Username: ${s.username}`);
if (s.email) console.log(` Email: ${s.email}`);
if (s.userId) console.log(` User ID: ${s.userId}`);
if (s.subscriptionPlan) console.log(` Plan: ${s.subscriptionPlan}`);
});
// ── usage ─────────────────────────────────────────────
program
.command('usage')
.description('View usage statistics')
.option('--month <YYYY-MM>', 'Month to query (default: current)')
.option('--daily', 'Group by day')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(async (options: { daily?: boolean; json?: string | boolean; month?: string }) => {
const client = await getTrpcClient();
const input: { mo?: string } = {};
if (options.month) input.mo = options.month;
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(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;
}
// 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)',
});
});
}
+392
View File
@@ -0,0 +1,392 @@
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../auth/resolveToken', () => ({
resolveToken: vi.fn().mockResolvedValue({ token: 'test-token', userId: 'test-user' }),
}));
vi.mock('../settings', () => ({
loadSettings: vi.fn().mockReturnValue(null),
saveSettings: vi.fn(),
}));
vi.mock('../utils/logger', () => ({
log: {
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
toolCall: vi.fn(),
toolResult: vi.fn(),
warn: vi.fn(),
},
setVerbose: vi.fn(),
}));
vi.mock('../tools/shell', () => ({
cleanupAllProcesses: vi.fn(),
}));
let mockRunningPid: number | null = null;
let mockSpawnedPid = 0;
let mockStatus: any = null;
vi.mock('../daemon/manager', () => ({
appendLog: vi.fn(),
getLogPath: vi.fn().mockReturnValue('/tmp/test-daemon.log'),
getRunningDaemonPid: vi.fn().mockImplementation(() => mockRunningPid),
readStatus: vi.fn().mockImplementation(() => mockStatus),
removePid: vi.fn(),
removeStatus: vi.fn(),
spawnDaemon: vi.fn().mockImplementation(() => {
mockSpawnedPid = 99999;
return mockSpawnedPid;
}),
stopDaemon: vi.fn().mockImplementation(() => {
if (mockRunningPid !== null) {
mockRunningPid = null;
return true;
}
return false;
}),
writeStatus: vi.fn(),
}));
vi.mock('../tools', () => ({
executeToolCall: vi.fn().mockResolvedValue({
content: 'tool result',
success: true,
}),
}));
let clientEventHandlers: Record<string, (...args: any[]) => any> = {};
let clientOptions: any = {};
let connectCalled = false;
let lastSentToolResponse: any = null;
let lastSentSystemInfoResponse: any = null;
vi.mock('@lobechat/device-gateway-client', () => ({
GatewayClient: vi.fn().mockImplementation((opts: any) => {
clientOptions = opts;
clientEventHandlers = {};
connectCalled = false;
lastSentToolResponse = null;
lastSentSystemInfoResponse = null;
return {
connect: vi.fn().mockImplementation(async () => {
connectCalled = true;
}),
currentDeviceId: 'mock-device-id',
disconnect: vi.fn(),
on: vi.fn().mockImplementation((event: string, handler: (...args: any[]) => any) => {
clientEventHandlers[event] = handler;
}),
sendSystemInfoResponse: vi.fn().mockImplementation((data: any) => {
lastSentSystemInfoResponse = data;
}),
sendToolCallResponse: vi.fn().mockImplementation((data: any) => {
lastSentToolResponse = data;
}),
};
}),
}));
// eslint-disable-next-line import-x/first
import { resolveToken } from '../auth/resolveToken';
// eslint-disable-next-line import-x/first
import { spawnDaemon, stopDaemon } from '../daemon/manager';
// eslint-disable-next-line import-x/first
import { loadSettings, saveSettings } from '../settings';
// eslint-disable-next-line import-x/first
import { executeToolCall } from '../tools';
// eslint-disable-next-line import-x/first
import { cleanupAllProcesses } from '../tools/shell';
// eslint-disable-next-line import-x/first
import { log, setVerbose } from '../utils/logger';
// eslint-disable-next-line import-x/first
import { registerConnectCommand } from './connect';
describe('connect command', () => {
let exitSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
mockRunningPid = null;
mockSpawnedPid = 0;
mockStatus = null;
});
afterEach(() => {
exitSpy.mockRestore();
vi.clearAllMocks();
});
function createProgram() {
const program = new Command();
program.exitOverride();
registerConnectCommand(program);
return program;
}
it('should connect to gateway', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect']);
expect(connectCalled).toBe(true);
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('LobeHub CLI'));
});
it('should require explicit gateway for custom login server', async () => {
vi.mocked(loadSettings).mockReturnValueOnce({ serverUrl: 'https://self-hosted.example.com' });
const program = createProgram();
await expect(program.parseAsync(['node', 'test', 'connect'])).rejects.toThrow('process.exit');
expect(log.error).toHaveBeenCalledWith(
"Current login uses custom --server https://self-hosted.example.com. Please also provide '--gateway <url>' for the device gateway.",
);
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should use explicit gateway for custom login server', async () => {
vi.mocked(loadSettings).mockReturnValueOnce({ serverUrl: 'https://self-hosted.example.com' });
const program = createProgram();
await program.parseAsync([
'node',
'test',
'connect',
'--gateway',
'https://gateway.example.com/',
]);
expect(clientOptions.gatewayUrl).toBe('https://gateway.example.com');
expect(saveSettings).toHaveBeenCalledWith({
gatewayUrl: 'https://gateway.example.com',
serverUrl: 'https://self-hosted.example.com',
});
});
it('should handle tool call requests', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect']);
// Trigger tool call
await clientEventHandlers['tool_call_request']?.({
requestId: 'req-1',
toolCall: { apiName: 'readLocalFile', arguments: '{"path":"/test"}', identifier: 'test' },
type: 'tool_call_request',
});
expect(executeToolCall).toHaveBeenCalledWith('readLocalFile', '{"path":"/test"}');
expect(lastSentToolResponse).toEqual({
requestId: 'req-1',
result: { content: 'tool result', error: undefined, success: true },
});
});
it('should handle system info requests', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect']);
clientEventHandlers['system_info_request']?.({
requestId: 'req-2',
type: 'system_info_request',
});
expect(lastSentSystemInfoResponse).toBeDefined();
expect(lastSentSystemInfoResponse.requestId).toBe('req-2');
expect(lastSentSystemInfoResponse.result.success).toBe(true);
expect(lastSentSystemInfoResponse.result.systemInfo).toHaveProperty('homePath');
expect(lastSentSystemInfoResponse.result.systemInfo).toHaveProperty('arch');
});
it('should handle auth_failed', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect']);
clientEventHandlers['auth_failed']?.('invalid token');
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Authentication failed'));
expect(cleanupAllProcesses).toHaveBeenCalled();
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should handle auth_expired', async () => {
vi.mocked(resolveToken).mockResolvedValueOnce({ token: 'new-tok', userId: 'user' });
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect']);
await clientEventHandlers['auth_expired']?.();
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('expired'));
expect(cleanupAllProcesses).toHaveBeenCalled();
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should handle error event', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect']);
clientEventHandlers['error']?.(new Error('connection lost'));
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('connection lost'));
});
it('should set verbose mode when -v flag is passed', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect', '-v']);
expect(setVerbose).toHaveBeenCalledWith(true);
});
it('should handle SIGINT', async () => {
const sigintHandlers: Array<() => void> = [];
const origOn = process.on;
vi.spyOn(process, 'on').mockImplementation((event: any, handler: any) => {
if (event === 'SIGINT') sigintHandlers.push(handler);
return origOn.call(process, event, handler);
});
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect']);
// Trigger SIGINT handler
for (const handler of sigintHandlers) {
handler();
}
expect(cleanupAllProcesses).toHaveBeenCalled();
});
it('should handle auth_expired when refresh fails', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect']);
// After initial connect, mock resolveToken to return falsy for the refresh attempt
vi.mocked(resolveToken).mockResolvedValueOnce(undefined as any);
await clientEventHandlers['auth_expired']?.();
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Could not refresh'));
expect(cleanupAllProcesses).toHaveBeenCalled();
});
it('should handle SIGTERM', async () => {
const sigtermHandlers: Array<() => void> = [];
const origOn = process.on;
vi.spyOn(process, 'on').mockImplementation((event: any, handler: any) => {
if (event === 'SIGTERM') sigtermHandlers.push(handler);
return origOn.call(process, event, handler);
});
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect']);
for (const handler of sigtermHandlers) {
handler();
}
expect(cleanupAllProcesses).toHaveBeenCalled();
});
it('should generate correct system info with Movies for non-linux', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect']);
clientEventHandlers['system_info_request']?.({
requestId: 'req-3',
type: 'system_info_request',
});
const sysInfo = lastSentSystemInfoResponse.result.systemInfo;
// On macOS (darwin), video dir should be Movies
if (process.platform !== 'linux') {
expect(sysInfo.videosPath).toContain('Movies');
} else {
expect(sysInfo.videosPath).toContain('Videos');
}
});
describe('--daemon flag', () => {
it('should spawn daemon and exit', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect', '--daemon']);
expect(spawnDaemon).toHaveBeenCalled();
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Daemon started'));
});
it('should refuse if daemon already running', async () => {
mockRunningPid = 12345;
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect', '--daemon']);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('already running'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
describe('connect stop', () => {
it('should stop running daemon', async () => {
mockRunningPid = 12345;
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect', 'stop']);
expect(stopDaemon).toHaveBeenCalled();
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Daemon stopped'));
});
it('should warn if no daemon is running', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect', 'stop']);
expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('No daemon'));
});
});
describe('connect status', () => {
it('should show no daemon running', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect', 'status']);
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('No daemon'));
});
it('should show daemon status', async () => {
mockRunningPid = 12345;
mockStatus = {
connectionStatus: 'connected',
gatewayUrl: 'https://gateway.test.com',
pid: 12345,
startedAt: new Date(Date.now() - 3600_000).toISOString(),
};
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect', 'status']);
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Daemon Status'));
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('12345'));
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('connected'));
});
});
describe('connect restart', () => {
it('should stop and start daemon', async () => {
mockRunningPid = 12345;
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect', 'restart']);
expect(stopDaemon).toHaveBeenCalled();
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Stopped existing'));
expect(spawnDaemon).toHaveBeenCalled();
});
it('should start daemon even if none was running', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect', 'restart']);
expect(spawnDaemon).toHaveBeenCalled();
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Daemon started'));
});
});
});
+375
View File
@@ -0,0 +1,375 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type {
DeviceSystemInfo,
SystemInfoRequestMessage,
ToolCallRequestMessage,
} from '@lobechat/device-gateway-client';
import { GatewayClient } from '@lobechat/device-gateway-client';
import type { Command } from 'commander';
import { resolveToken } from '../auth/resolveToken';
import { OFFICIAL_GATEWAY_URL } from '../constants/urls';
import {
appendLog,
getLogPath,
getRunningDaemonPid,
readStatus,
removePid,
removeStatus,
spawnDaemon,
stopDaemon,
writeStatus,
} from '../daemon/manager';
import { loadSettings, saveSettings } from '../settings';
import { executeToolCall } from '../tools';
import { cleanupAllProcesses } from '../tools/shell';
import { log, setVerbose } from '../utils/logger';
interface ConnectOptions {
daemon?: boolean;
daemonChild?: boolean;
deviceId?: string;
gateway?: string;
token?: string;
verbose?: boolean;
}
export function registerConnectCommand(program: Command) {
const connectCmd = program
.command('connect')
.description('Connect to the device gateway and listen for tool calls')
.option('--token <jwt>', 'JWT access token')
.option('--gateway <url>', 'Device gateway URL')
.option('--device-id <id>', 'Device ID (auto-generated if not provided)')
.option('-v, --verbose', 'Enable verbose logging')
.option('-d, --daemon', 'Run as a background daemon process')
.option('--daemon-child', 'Internal: runs as the daemon child process')
.action(async (options: ConnectOptions) => {
if (options.verbose) setVerbose(true);
// --daemon: spawn detached child and exit
if (options.daemon) {
return handleDaemonStart(options);
}
// --daemon-child: running inside daemon, redirect logging
const isDaemonChild = options.daemonChild || process.env.LOBEHUB_DAEMON === '1';
await runConnect(options, isDaemonChild);
});
// Subcommands
connectCmd
.command('stop')
.description('Stop the background daemon process')
.action(() => {
const stopped = stopDaemon();
if (stopped) {
log.info('Daemon stopped.');
} else {
log.warn('No daemon is running.');
}
});
connectCmd
.command('status')
.description('Show background daemon status')
.action(() => {
const pid = getRunningDaemonPid();
if (pid === null) {
log.info('No daemon is running.');
return;
}
const status = readStatus();
log.info('─── Daemon Status ───');
log.info(` PID : ${pid}`);
if (status) {
log.info(` Started at : ${status.startedAt}`);
log.info(` Connection : ${status.connectionStatus}`);
log.info(` Gateway : ${status.gatewayUrl}`);
const uptime = formatUptime(new Date(status.startedAt));
log.info(` Uptime : ${uptime}`);
}
log.info('─────────────────────');
});
connectCmd
.command('logs')
.description('Tail the daemon log file')
.option('-n, --lines <count>', 'Number of lines to show', '50')
.option('-f, --follow', 'Follow log output')
.action(async (opts: { follow?: boolean; lines?: string }) => {
const logPath = getLogPath();
if (!fs.existsSync(logPath)) {
log.warn('No log file found. Start the daemon first.');
return;
}
const lines = opts.lines || '50';
const args = [`-n`, lines];
if (opts.follow) args.push('-f');
// Use tail directly — this hands control to the child process
try {
const { execFileSync } = await import('node:child_process');
execFileSync('tail', [...args, logPath], { stdio: 'inherit' });
} catch {
// tail -f exits via SIGINT, which throws — that's fine
}
});
connectCmd
.command('restart')
.description('Restart the background daemon process')
.option('--token <jwt>', 'JWT access token')
.option('--gateway <url>', 'Device gateway URL')
.option('--device-id <id>', 'Device ID')
.option('-v, --verbose', 'Enable verbose logging')
.action((options: ConnectOptions) => {
const wasStopped = stopDaemon();
if (wasStopped) {
log.info('Stopped existing daemon.');
}
handleDaemonStart({ ...options, daemon: true });
});
}
// --- Internal helpers ---
function handleDaemonStart(options: ConnectOptions) {
const existingPid = getRunningDaemonPid();
if (existingPid !== null) {
log.error(`Daemon is already running (PID ${existingPid}).`);
log.error("Use 'lh connect stop' to stop it, or 'lh connect restart' to restart.");
process.exit(1);
}
// Build args to re-run with --daemon-child
const args = buildDaemonArgs(options);
const pid = spawnDaemon(args);
log.info(`Daemon started (PID ${pid}).`);
log.info(` Logs: ${getLogPath()}`);
log.info(" Run 'lh connect status' to check connection.");
log.info(" Run 'lh connect stop' to stop.");
}
function buildDaemonArgs(options: ConnectOptions): string[] {
// Find the entry script (process.argv[1])
const script = process.argv[1];
const args = [script, 'connect'];
if (options.token) args.push('--token', options.token);
if (options.gateway) args.push('--gateway', options.gateway);
if (options.deviceId) args.push('--device-id', options.deviceId);
if (options.verbose) args.push('--verbose');
return args;
}
async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
const auth = await resolveToken(options);
const settings = loadSettings();
const gatewayUrl = options.gateway?.replace(/\/$/, '') || settings?.gatewayUrl;
if (!gatewayUrl && settings?.serverUrl) {
log.error(
`Current login uses custom --server ${settings?.serverUrl}. Please also provide '--gateway <url>' for the device gateway.`,
);
process.exit(1);
throw new Error('process.exit');
}
if (options.gateway && gatewayUrl) {
saveSettings({ ...settings, gatewayUrl });
}
const resolvedGatewayUrl = gatewayUrl || OFFICIAL_GATEWAY_URL;
const client = new GatewayClient({
deviceId: options.deviceId,
gatewayUrl: resolvedGatewayUrl,
logger: isDaemonChild ? createDaemonLogger() : log,
token: auth.token,
userId: auth.userId,
});
const info = (msg: string) => {
if (isDaemonChild) appendLog(msg);
else log.info(msg);
};
const error = (msg: string) => {
if (isDaemonChild) appendLog(`[ERROR] ${msg}`);
else log.error(msg);
};
// Print device info
info('─── LobeHub CLI ───');
info(` Device ID : ${client.currentDeviceId}`);
info(` Hostname : ${os.hostname()}`);
info(` Platform : ${process.platform}`);
info(` Gateway : ${resolvedGatewayUrl}`);
info(` Auth : jwt`);
info(` Mode : ${isDaemonChild ? 'daemon' : 'foreground'}`);
info('───────────────────');
// Update status file for daemon mode
const updateStatus = (connectionStatus: string) => {
if (isDaemonChild) {
writeStatus({
connectionStatus,
gatewayUrl: resolvedGatewayUrl,
pid: process.pid,
startedAt: startedAt.toISOString(),
});
}
};
const startedAt = new Date();
updateStatus('connecting');
// Handle system info requests
client.on('system_info_request', (request: SystemInfoRequestMessage) => {
info(`Received system_info_request: requestId=${request.requestId}`);
const systemInfo = collectSystemInfo();
client.sendSystemInfoResponse({
requestId: request.requestId,
result: { success: true, systemInfo },
});
});
// Handle tool call requests
client.on('tool_call_request', async (request: ToolCallRequestMessage) => {
const { requestId, toolCall } = request;
if (isDaemonChild) {
appendLog(`[TOOL] ${toolCall.apiName} (${requestId})`);
} else {
log.toolCall(toolCall.apiName, requestId, toolCall.arguments);
}
const result = await executeToolCall(toolCall.apiName, toolCall.arguments);
if (isDaemonChild) {
appendLog(`[RESULT] ${result.success ? 'OK' : 'FAIL'} (${requestId})`);
} else {
log.toolResult(requestId, result.success, result.content);
}
client.sendToolCallResponse({
requestId,
result: {
content: result.content,
error: result.error,
success: result.success,
},
});
});
client.on('connected', () => {
updateStatus('connected');
});
client.on('disconnected', () => {
updateStatus('disconnected');
});
client.on('reconnecting', () => {
updateStatus('reconnecting');
});
// Handle auth failed
client.on('auth_failed', (reason) => {
error(`Authentication failed: ${reason}`);
error("Run 'lh login' to re-authenticate.");
cleanup();
process.exit(1);
});
// Handle auth expired
client.on('auth_expired', async () => {
error('Authentication expired. Attempting to refresh...');
const refreshed = await resolveToken({});
if (refreshed) {
info('Token refreshed. Please reconnect.');
} else {
error("Could not refresh token. Run 'lh login' to re-authenticate.");
}
cleanup();
process.exit(1);
});
// Handle errors
client.on('error', (err) => {
error(`Connection error: ${err.message}`);
});
// Graceful shutdown
const cleanup = () => {
info('Shutting down...');
cleanupAllProcesses();
client.disconnect();
if (isDaemonChild) {
removeStatus();
removePid();
}
};
process.on('SIGINT', () => {
cleanup();
process.exit(0);
});
process.on('SIGTERM', () => {
cleanup();
process.exit(0);
});
// Connect
await client.connect();
}
function createDaemonLogger() {
return {
debug: (msg: string) => appendLog(`[DEBUG] ${msg}`),
error: (msg: string) => appendLog(`[ERROR] ${msg}`),
info: (msg: string) => appendLog(`[INFO] ${msg}`),
warn: (msg: string) => appendLog(`[WARN] ${msg}`),
};
}
function formatUptime(startedAt: Date): string {
const diff = Date.now() - startedAt.getTime();
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days}d ${hours % 24}h ${minutes % 60}m`;
if (hours > 0) return `${hours}h ${minutes % 60}m ${seconds % 60}s`;
if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
return `${seconds}s`;
}
function collectSystemInfo(): DeviceSystemInfo {
const home = os.homedir();
const platform = process.platform;
const videosDir = platform === 'linux' ? 'Videos' : 'Movies';
return {
arch: os.arch(),
desktopPath: path.join(home, 'Desktop'),
documentsPath: path.join(home, 'Documents'),
downloadsPath: path.join(home, 'Downloads'),
homePath: home,
musicPath: path.join(home, 'Music'),
picturesPath: path.join(home, 'Pictures'),
userDataPath: path.join(home, '.lobehub'),
videosPath: path.join(home, videosDir),
workingDirectory: process.cwd(),
};
}
+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}`);
});
}
+647
View File
@@ -0,0 +1,647 @@
import fs from 'node:fs';
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Mock TRPC client — use vi.hoisted so the variable is available in vi.mock factories
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() },
},
},
}));
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(),
}));
// eslint-disable-next-line import-x/first
import { log } from '../utils/logger';
// eslint-disable-next-line import-x/first
import { registerDocCommand } from './doc';
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);
resetMocks(mockTrpcClient);
});
afterEach(() => {
exitSpy.mockRestore();
consoleSpy.mockRestore();
});
function createProgram() {
const program = new Command();
program.exitOverride();
registerDocCommand(program);
return program;
}
// ── list ──────────────────────────────────────────────
describe('list', () => {
it('should display documents in table format', async () => {
mockTrpcClient.document.queryDocuments.query.mockResolvedValue([
{
fileType: 'md',
id: 'doc1',
title: 'Meeting Notes',
updatedAt: new Date().toISOString(),
},
{ fileType: 'md', id: 'doc2', title: 'API Design', updatedAt: new Date().toISOString() },
]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'doc', 'list']);
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');
expect(consoleSpy.mock.calls[0][0]).toContain('TITLE');
});
it('should output JSON when --json flag is used', async () => {
const docs = [{ fileType: 'md', id: 'doc1', title: 'Test' }];
mockTrpcClient.document.queryDocuments.query.mockResolvedValue(docs);
const program = createProgram();
await program.parseAsync(['node', 'test', 'doc', 'list', '--json']);
expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(docs, null, 2));
});
it('should output JSON with selected fields', async () => {
const docs = [{ fileType: 'md', id: 'doc1', title: 'Test' }];
mockTrpcClient.document.queryDocuments.query.mockResolvedValue(docs);
const program = createProgram();
await program.parseAsync(['node', 'test', 'doc', 'list', '--json', 'id,title']);
const output = JSON.parse(consoleSpy.mock.calls[0][0]);
expect(output).toEqual([{ id: 'doc1', title: 'Test' }]);
});
it('should filter by file type', async () => {
mockTrpcClient.document.queryDocuments.query.mockResolvedValue([]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'doc', 'list', '--file-type', 'md']);
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 () => {
mockTrpcClient.document.queryDocuments.query.mockResolvedValue([]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'doc', 'list']);
expect(consoleSpy).toHaveBeenCalledWith('No documents found.');
});
it('should respect --limit flag', async () => {
mockTrpcClient.document.queryDocuments.query.mockResolvedValue([]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'doc', 'list', '-L', '10']);
expect(mockTrpcClient.document.queryDocuments.query).toHaveBeenCalledWith(
expect.objectContaining({ pageSize: 10 }),
);
});
});
// ── view ──────────────────────────────────────────────
describe('view', () => {
it('should display document content', async () => {
mockTrpcClient.document.getDocumentById.query.mockResolvedValue({
content: '# Hello World',
fileType: 'md',
id: 'doc1',
title: 'Test Doc',
updatedAt: new Date().toISOString(),
});
const program = createProgram();
await program.parseAsync(['node', 'test', 'doc', 'view', 'doc1']);
expect(mockTrpcClient.document.getDocumentById.query).toHaveBeenCalledWith({ id: 'doc1' });
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);
const program = createProgram();
await program.parseAsync(['node', 'test', 'doc', 'view', 'doc1', '--json']);
expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(doc, null, 2));
});
it('should exit with error when document not found', async () => {
mockTrpcClient.document.getDocumentById.query.mockResolvedValue(null);
const program = createProgram();
await program.parseAsync(['node', 'test', 'doc', 'view', 'nonexistent']);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('not found'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
// ── create ────────────────────────────────────────────
describe('create', () => {
it('should create a document with title and body', async () => {
mockTrpcClient.document.createDocument.mutate.mockResolvedValue({ id: 'new-doc' });
const program = createProgram();
await program.parseAsync([
'node',
'test',
'doc',
'create',
'--title',
'My Doc',
'--body',
'Hello',
]);
expect(mockTrpcClient.document.createDocument.mutate).toHaveBeenCalledWith(
expect.objectContaining({
content: 'Hello',
title: 'My Doc',
}),
);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('new-doc'));
});
it('should read content from file with --body-file', async () => {
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
vi.spyOn(fs, 'readFileSync').mockReturnValue('file content');
mockTrpcClient.document.createDocument.mutate.mockResolvedValue({ id: 'new-doc' });
const program = createProgram();
await program.parseAsync([
'node',
'test',
'doc',
'create',
'--title',
'From File',
'--body-file',
'./test.md',
]);
expect(mockTrpcClient.document.createDocument.mutate).toHaveBeenCalledWith(
expect.objectContaining({
content: 'file content',
title: 'From File',
}),
);
vi.restoreAllMocks();
});
it('should support --parent and --slug flags', async () => {
mockTrpcClient.document.createDocument.mutate.mockResolvedValue({ id: 'new-doc' });
const program = createProgram();
await program.parseAsync([
'node',
'test',
'doc',
'create',
'--title',
'Child Doc',
'--parent',
'parent-id',
'--slug',
'child-doc',
]);
expect(mockTrpcClient.document.createDocument.mutate).toHaveBeenCalledWith(
expect.objectContaining({
parentId: 'parent-id',
slug: 'child-doc',
title: 'Child Doc',
}),
);
});
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 ──────────────────────────────────────────────
describe('edit', () => {
it('should update document title', async () => {
mockTrpcClient.document.updateDocument.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync(['node', 'test', 'doc', 'edit', 'doc1', '--title', 'New Title']);
expect(mockTrpcClient.document.updateDocument.mutate).toHaveBeenCalledWith(
expect.objectContaining({
id: 'doc1',
title: 'New Title',
}),
);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Updated'));
});
it('should update document body', async () => {
mockTrpcClient.document.updateDocument.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync(['node', 'test', 'doc', 'edit', 'doc1', '--body', 'new content']);
expect(mockTrpcClient.document.updateDocument.mutate).toHaveBeenCalledWith(
expect.objectContaining({
content: 'new content',
id: 'doc1',
}),
);
});
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']);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('No changes specified'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
// ── delete ────────────────────────────────────────────
describe('delete', () => {
it('should delete a single document with --yes', async () => {
mockTrpcClient.document.deleteDocument.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync(['node', 'test', 'doc', 'delete', 'doc1', '--yes']);
expect(mockTrpcClient.document.deleteDocument.mutate).toHaveBeenCalledWith({ id: 'doc1' });
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Deleted'));
});
it('should delete multiple documents with --yes', async () => {
mockTrpcClient.document.deleteDocuments.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync(['node', 'test', 'doc', 'delete', 'doc1', 'doc2', '--yes']);
expect(mockTrpcClient.document.deleteDocuments.mutate).toHaveBeenCalledWith({
ids: ['doc1', 'doc2'],
});
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.');
});
});
});
+371
View File
@@ -0,0 +1,371 @@
import fs from 'node:fs';
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';
// ── Helpers ────────────────────────────────────────────────
function readBodyContent(options: { body?: string; bodyFile?: string }): string | undefined {
if (options.bodyFile) {
if (!fs.existsSync(options.bodyFile)) {
log.error(`File not found: ${options.bodyFile}`);
process.exit(1);
}
return fs.readFileSync(options.bodyFile, 'utf8');
}
return options.body;
}
// ── Command Registration ───────────────────────────────────
export function registerDocCommand(program: Command) {
const doc = program.command('doc').description('Manage documents');
// ── list ──────────────────────────────────────────────
doc
.command('list')
.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;
sourceType?: string;
}) => {
const client = await getTrpcClient();
const pageSize = Number.parseInt(options.limit || '30', 10);
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 (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) : '',
]);
printTable(rows, ['ID', 'TITLE', 'TYPE', 'UPDATED']);
},
);
// ── view ──────────────────────────────────────────────
doc
.command('view <id>')
.description('View a document')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(async (id: string, options: { json?: string | boolean }) => {
const client = await getTrpcClient();
const document = await client.document.getDocumentById.query({ id });
if (!document) {
log.error(`Document not found: ${id}`);
process.exit(1);
return;
}
if (options.json !== undefined) {
const fields = typeof options.json === 'string' ? options.json : undefined;
outputJson(document, fields);
return;
}
// Human-readable output
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();
if (document.content) {
console.log(document.content);
} else {
console.log(pc.dim('(no content)'));
}
});
// ── create ────────────────────────────────────────────
doc
.command('create')
.description('Create a new document')
.requiredOption('-t, --title <title>', 'Document title')
.option('-b, --body <content>', 'Document content')
.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;
}) => {
const content = readBodyContent(options);
const client = await getTrpcClient();
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,
});
console.log(`${pc.green('✓')} Created document ${pc.bold(result.id)}`);
},
);
// ── 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
.command('edit <id>')
.description('Edit a document')
.option('-t, --title <title>', 'New title')
.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;
fileType?: string;
parent?: string;
title?: string;
},
) => {
const content = readBodyContent(options);
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);
}
const client = await getTrpcClient();
const params: Record<string, any> = { id };
if (options.title) params.title = options.title;
if (content !== undefined) {
params.content = content;
params.editorData = JSON.stringify({ content, type: 'doc' });
}
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)}`);
},
);
// ── delete ────────────────────────────────────────────
doc
.command('delete <ids...>')
.description('Delete one or more documents')
.option('--yes', 'Skip confirmation prompt')
.action(async (ids: string[], options: { yes?: boolean }) => {
if (!options.yes) {
const confirmed = await confirm(
`Are you sure you want to delete ${ids.length} document(s)?`,
);
if (!confirmed) {
console.log('Cancelled.');
return;
}
}
const client = await getTrpcClient();
if (ids.length === 1) {
await client.document.deleteDocument.mutate({ id: ids[0] });
} else {
await client.document.deleteDocuments.mutate({ ids });
}
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']);
});
}
+600
View File
@@ -0,0 +1,600 @@
import { Command } from 'commander';
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() },
runGet: { query: vi.fn() },
runSetStatus: { mutate: vi.fn() },
runTopicReportResult: { mutate: vi.fn() },
runTopicsList: { query: vi.fn() },
testCasesCount: { query: vi.fn() },
threadsList: { query: vi.fn() },
},
},
}));
const { getTrpcClientMock } = vi.hoisted(() => ({
getTrpcClientMock: vi.fn(),
}));
vi.mock('../api/client', () => ({
getTrpcClient: getTrpcClientMock,
}));
vi.mock('../utils/logger', () => ({
log: {
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
},
setVerbose: vi.fn(),
}));
// eslint-disable-next-line import-x/first
import { log } from '../utils/logger';
// eslint-disable-next-line import-x/first
import { registerEvalCommand } from './eval';
describe('eval command', () => {
let exitSpy: ReturnType<typeof vi.spyOn>;
let logSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
getTrpcClientMock.mockResolvedValue(mockTrpcClient);
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
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();
}
}
}
});
afterEach(() => {
exitSpy.mockRestore();
logSpy.mockRestore();
vi.clearAllMocks();
});
const createProgram = () => {
const program = new Command();
program.exitOverride();
registerEvalCommand(program);
return program;
};
// ============================================
// 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();
});
it('should create a benchmark', async () => {
mockTrpcClient.agentEval.createBenchmark.mutate.mockResolvedValue({ id: 'b1' });
const program = createProgram();
await program.parseAsync([
'node',
'test',
'eval',
'benchmark',
'create',
'--identifier',
'test-bench',
'-n',
'Test Bench',
'--json',
]);
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',
});
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'));
});
});
// ============================================
// 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',
});
});
it('should report run-topic result', async () => {
mockTrpcClient.agentEvalExternal.runTopicReportResult.mutate.mockResolvedValue({
success: true,
});
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',
});
});
});
// ============================================
// 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',
'thread',
'list',
'--topic-id',
'topic-1',
'--json',
]);
expect(mockTrpcClient.agentEvalExternal.threadsList.query).toHaveBeenCalledWith({
topicId: 'topic-1',
});
});
});
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',
'message',
'list',
'--topic-id',
'topic-1',
'--thread-id',
'thread-1',
'--json',
]);
expect(mockTrpcClient.agentEvalExternal.messagesList.query).toHaveBeenCalledWith({
threadId: 'thread-1',
topicId: 'topic-1',
});
});
});
// ============================================
// 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);
});
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', 'thread', 'list', '--topic-id', 'topic-1']);
expect(log.error).toHaveBeenCalledWith('boom');
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
});
+808
View File
@@ -0,0 +1,808 @@
import type { Command } from 'commander';
import { InvalidArgumentError } from 'commander';
import pc from 'picocolors';
import { getTrpcClient } from '../api/client';
import { log } from '../utils/logger';
const JSON_VERSION = 'v1' as const;
interface JsonError {
code?: string;
message: string;
}
interface JsonEnvelope<T> {
data: T | null;
error: JsonError | null;
ok: boolean;
version: typeof JSON_VERSION;
}
interface JsonOption {
json?: boolean;
}
const printJson = (data: unknown) => {
console.log(JSON.stringify(data, null, 2));
};
const outputJsonSuccess = (data: unknown) => {
const payload: JsonEnvelope<unknown> = {
data,
error: null,
ok: true,
version: JSON_VERSION,
};
printJson(payload);
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;
const toJsonError = (error: unknown): JsonError => {
if (error instanceof Error) {
const maybeData = (error as Error & { data?: { code?: string } }).data;
const code = maybeData?.code;
return {
code: typeof code === 'string' ? code : undefined,
message: error.message,
};
}
if (isRecord(error)) {
const code = typeof error.code === 'string' ? error.code : undefined;
const message = typeof error.message === 'string' ? error.message : 'Unknown error';
return { code, message };
}
return { message: String(error) };
};
const handleCommandError = (error: unknown, json: boolean) => {
const normalized = toJsonError(error);
if (json) {
const payload: JsonEnvelope<null> = {
data: null,
error: normalized,
ok: false,
version: JSON_VERSION,
};
printJson(payload);
} else {
log.error(normalized.message);
}
process.exit(1);
};
const parseScore = (value: string) => {
const score = Number(value);
if (!Number.isFinite(score)) {
throw new InvalidArgumentError(`Invalid score: ${value}`);
}
return score;
};
const parseBoolean = (value: string) => {
const normalized = value.trim().toLowerCase();
if (['1', 'true', 'yes'].includes(normalized)) return true;
if (['0', 'false', 'no'].includes(normalized)) return false;
throw new InvalidArgumentError(`Invalid boolean value: ${value}`);
};
const parseResultJson = (value: string) => {
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
throw new InvalidArgumentError('Invalid JSON value for --result-json');
}
if (!isRecord(parsed) || Array.isArray(parsed)) {
throw new InvalidArgumentError('--result-json must be a JSON object');
}
return parsed;
};
const parseRunStatus = (value: string) => {
if (value !== 'completed' && value !== 'external') {
throw new InvalidArgumentError("Only 'completed' and 'external' are supported");
}
return value as 'completed' | 'external';
};
const executeCommand = async (
options: JsonOption,
action: () => Promise<unknown>,
successMessage?: string,
) => {
try {
const data = await action();
if (options.json) {
outputJsonSuccess(data);
return;
}
if (successMessage) {
console.log(`${pc.green('OK')} ${successMessage}`);
return;
}
printJson(data);
} catch (error) {
handleCommandError(error, Boolean(options.json));
}
};
export function registerEvalCommand(program: Command) {
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('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: 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();
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 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: JsonOption & { id: string; status: 'completed' | 'external' }) =>
executeCommand(
options,
async () => {
const client = await getTrpcClient();
return client.agentEvalExternal.runSetStatus.mutate({
runId: options.id,
status: options.status,
});
},
`Run ${pc.bold(options.id)} status updated to ${pc.bold(options.status)}`,
),
);
// ============================================
// Run-Topic Operations (external eval API)
// ============================================
const runTopicCmd = evalCmd.command('run-topic').description('Manage evaluation 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: JsonOption & { onlyExternal?: boolean; runId: string }) =>
executeCommand(options, async () => {
const client = await getTrpcClient();
return client.agentEvalExternal.runTopicsList.query({
onlyExternal: Boolean(options.onlyExternal),
runId: options.runId,
});
}),
);
runTopicCmd
.command('report-result')
.description('Report one evaluation result for a run topic')
.requiredOption('--run-id <id>', 'Run ID')
.requiredOption('--topic-id <id>', 'Topic ID')
.option('--thread-id <id>', 'Thread ID (required for k > 1)')
.requiredOption('--score <score>', 'Evaluation score', parseScore)
.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: JsonOption & {
correct: boolean;
resultJson: Record<string, unknown>;
runId: string;
score: number;
threadId?: string;
topicId: string;
},
) =>
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,
});
}),
);
}
+177
View File
@@ -0,0 +1,177 @@
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { log } from '../utils/logger';
import { registerFileCommand } from './file';
const { mockTrpcClient } = vi.hoisted(() => ({
mockTrpcClient: {
file: {
getFileItemById: { query: vi.fn() },
getFiles: { query: vi.fn() },
recentFiles: { query: vi.fn() },
removeFile: { mutate: vi.fn() },
removeFiles: { 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('file 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.file)) {
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();
registerFileCommand(program);
return program;
}
describe('list', () => {
it('should display files in table format', async () => {
mockTrpcClient.file.getFiles.query.mockResolvedValue([
{
fileType: 'pdf',
id: 'f1',
name: 'doc.pdf',
size: 2048,
updatedAt: new Date().toISOString(),
},
]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'file', 'list']);
expect(consoleSpy).toHaveBeenCalledTimes(2); // header + 1 row
expect(consoleSpy.mock.calls[0][0]).toContain('ID');
});
it('should output JSON when --json flag is used', async () => {
const items = [{ id: 'f1', name: 'doc.pdf' }];
mockTrpcClient.file.getFiles.query.mockResolvedValue(items);
const program = createProgram();
await program.parseAsync(['node', 'test', 'file', 'list', '--json']);
expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(items, null, 2));
});
it('should show message when no files found', async () => {
mockTrpcClient.file.getFiles.query.mockResolvedValue([]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'file', 'list']);
expect(consoleSpy).toHaveBeenCalledWith('No files found.');
});
it('should filter by knowledge base ID', async () => {
mockTrpcClient.file.getFiles.query.mockResolvedValue([]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'file', 'list', '--kb-id', 'kb1']);
expect(mockTrpcClient.file.getFiles.query).toHaveBeenCalledWith(
expect.objectContaining({ knowledgeBaseId: 'kb1' }),
);
});
});
describe('view', () => {
it('should display file details', async () => {
mockTrpcClient.file.getFileItemById.query.mockResolvedValue({
fileType: 'pdf',
id: 'f1',
name: 'doc.pdf',
size: 2048,
});
const program = createProgram();
await program.parseAsync(['node', 'test', 'file', 'view', 'f1']);
expect(mockTrpcClient.file.getFileItemById.query).toHaveBeenCalledWith({ id: 'f1' });
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('doc.pdf'));
});
it('should exit when not found', async () => {
mockTrpcClient.file.getFileItemById.query.mockResolvedValue(null);
const program = createProgram();
await program.parseAsync(['node', 'test', 'file', 'view', 'nonexistent']);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('not found'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
describe('delete', () => {
it('should delete a single file with --yes', async () => {
mockTrpcClient.file.removeFile.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync(['node', 'test', 'file', 'delete', 'f1', '--yes']);
expect(mockTrpcClient.file.removeFile.mutate).toHaveBeenCalledWith({ id: 'f1' });
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Deleted'));
});
it('should delete multiple files with --yes', async () => {
mockTrpcClient.file.removeFiles.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync(['node', 'test', 'file', 'delete', 'f1', 'f2', '--yes']);
expect(mockTrpcClient.file.removeFiles.mutate).toHaveBeenCalledWith({ ids: ['f1', 'f2'] });
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Deleted 2'));
});
});
describe('recent', () => {
it('should list recent files', async () => {
mockTrpcClient.file.recentFiles.query.mockResolvedValue([
{ fileType: 'pdf', id: 'f1', name: 'doc.pdf', updatedAt: new Date().toISOString() },
]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'file', 'recent']);
expect(mockTrpcClient.file.recentFiles.query).toHaveBeenCalledWith({ limit: 10 });
expect(consoleSpy).toHaveBeenCalledTimes(2); // header + 1 row
});
it('should show message when no recent files', async () => {
mockTrpcClient.file.recentFiles.query.mockResolvedValue([]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'file', 'recent']);
expect(consoleSpy).toHaveBeenCalledWith('No recent files.');
});
});
});
+147
View File
@@ -0,0 +1,147 @@
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 registerFileCommand(program: Command) {
const file = program.command('file').description('Manage files');
// ── list ──────────────────────────────────────────────
file
.command('list')
.description('List files')
.option('--kb-id <id>', 'Filter by knowledge base ID')
.option('-L, --limit <n>', 'Maximum number of items', '30')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(async (options: { json?: string | boolean; kbId?: string; limit?: string }) => {
const client = await getTrpcClient();
const input: any = {};
if (options.kbId) input.knowledgeBaseId = options.kbId;
if (options.limit) input.limit = Number.parseInt(options.limit, 10);
const result = await client.file.getFiles.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 files found.');
return;
}
const rows = items.map((f: any) => [
f.id,
truncate(f.name || f.filename || '', 50),
f.fileType || '',
f.size ? `${Math.round(f.size / 1024)}KB` : '',
f.updatedAt ? timeAgo(f.updatedAt) : '',
]);
printTable(rows, ['ID', 'NAME', 'TYPE', 'SIZE', 'UPDATED']);
});
// ── view ──────────────────────────────────────────────
file
.command('view <id>')
.description('View file details')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(async (id: string, options: { json?: string | boolean }) => {
const client = await getTrpcClient();
const result = await client.file.getFileItemById.query({ id });
if (!result) {
log.error(`File not found: ${id}`);
process.exit(1);
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.name || r.filename || 'Unknown'));
const meta: string[] = [];
if (r.fileType) meta.push(r.fileType);
if (r.size) meta.push(`${Math.round(r.size / 1024)}KB`);
if (r.updatedAt) meta.push(`Updated ${timeAgo(r.updatedAt)}`);
if (meta.length > 0) console.log(pc.dim(meta.join(' · ')));
if (r.chunkingStatus || r.embeddingStatus) {
console.log();
if (r.chunkingStatus) console.log(` Chunking: ${r.chunkingStatus}`);
if (r.embeddingStatus) console.log(` Embedding: ${r.embeddingStatus}`);
}
});
// ── delete ────────────────────────────────────────────
file
.command('delete <ids...>')
.description('Delete one or more files')
.option('--yes', 'Skip confirmation prompt')
.action(async (ids: string[], options: { yes?: boolean }) => {
if (!options.yes) {
const confirmed = await confirm(`Are you sure you want to delete ${ids.length} file(s)?`);
if (!confirmed) {
console.log('Cancelled.');
return;
}
}
const client = await getTrpcClient();
if (ids.length === 1) {
await client.file.removeFile.mutate({ id: ids[0] });
} else {
await client.file.removeFiles.mutate({ ids });
}
console.log(`${pc.green('✓')} Deleted ${ids.length} file(s)`);
});
// ── recent ────────────────────────────────────────────
file
.command('recent')
.description('List recently accessed files')
.option('-L, --limit <n>', 'Number of items', '10')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(async (options: { json?: string | boolean; limit?: string }) => {
const client = await getTrpcClient();
const limit = Number.parseInt(options.limit || '10', 10);
const result = await client.file.recentFiles.query({ limit });
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 recent files.');
return;
}
const rows = items.map((f: any) => [
f.id,
truncate(f.name || f.filename || '', 50),
f.fileType || '',
f.updatedAt ? timeAgo(f.updatedAt) : '',
]);
printTable(rows, ['ID', 'NAME', 'TYPE', 'UPDATED']);
});
}
+372
View File
@@ -0,0 +1,372 @@
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { log } from '../utils/logger';
import { registerGenerateCommand } from './generate';
const { mockTrpcClient } = vi.hoisted(() => ({
mockTrpcClient: {
generation: {
getGenerationStatus: { query: vi.fn() },
},
generationTopic: {
createTopic: { mutate: vi.fn() },
getAllGenerationTopics: { query: vi.fn() },
},
image: {
createImage: { mutate: vi.fn() },
},
video: {
createVideo: { mutate: vi.fn() },
},
},
}));
const { getTrpcClient: mockGetTrpcClient } = vi.hoisted(() => ({
getTrpcClient: vi.fn(),
}));
const { getAuthInfo: mockGetAuthInfo } = vi.hoisted(() => ({
getAuthInfo: vi.fn(),
}));
const { writeFileSync: mockWriteFileSync } = vi.hoisted(() => ({
writeFileSync: vi.fn(),
}));
vi.mock('../api/client', () => ({ getTrpcClient: mockGetTrpcClient }));
vi.mock('../api/http', () => ({ getAuthInfo: mockGetAuthInfo }));
vi.mock('node:fs', async (importOriginal) => {
const actual: Record<string, unknown> = await importOriginal();
return { ...actual, writeFileSync: mockWriteFileSync };
});
vi.mock('../utils/logger', () => ({
log: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() },
setVerbose: vi.fn(),
}));
describe('generate command', () => {
let exitSpy: ReturnType<typeof vi.spyOn>;
let consoleSpy: ReturnType<typeof vi.spyOn>;
let stdoutSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
mockGetTrpcClient.mockResolvedValue(mockTrpcClient);
mockGetAuthInfo.mockResolvedValue({
accessToken: 'test-token',
headers: {
'Content-Type': 'application/json',
'Oidc-Auth': 'test-token',
'X-lobe-chat-auth': 'test-xor-token',
},
serverUrl: 'https://app.lobehub.com',
});
for (const router of Object.values(mockTrpcClient)) {
for (const method of Object.values(router)) {
for (const fn of Object.values(method)) {
(fn as ReturnType<typeof vi.fn>).mockReset();
}
}
}
});
afterEach(() => {
exitSpy.mockRestore();
consoleSpy.mockRestore();
stdoutSpy.mockRestore();
vi.restoreAllMocks();
});
function createProgram() {
const program = new Command();
program.exitOverride();
registerGenerateCommand(program);
return program;
}
describe('text', () => {
it('should default to non-streaming and output plain text', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
json: vi.fn().mockResolvedValue({
choices: [{ message: { content: 'Response text' } }],
}),
ok: true,
}),
);
const program = createProgram();
await program.parseAsync(['node', 'test', 'generate', 'text', 'Hello']);
// Should send stream: false by default
const fetchCall = vi.mocked(fetch).mock.calls[0];
const body = JSON.parse(fetchCall[1]!.body as string);
expect(body.stream).toBe(false);
expect(stdoutSpy).toHaveBeenCalledWith('Response text');
});
it('should output JSON when --json is used', async () => {
const responseBody = {
choices: [{ message: { content: 'Hello' } }],
model: 'gpt-4o-mini',
usage: { completion_tokens: 5, prompt_tokens: 10 },
};
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
json: vi.fn().mockResolvedValue(responseBody),
ok: true,
}),
);
const program = createProgram();
await program.parseAsync(['node', 'test', 'generate', 'text', 'Hello', '--json']);
expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(responseBody, null, 2));
});
it('should stream when --stream is explicitly passed', async () => {
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
controller.enqueue(
encoder.encode('data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n'),
);
controller.enqueue(encoder.encode('data: [DONE]\n\n'));
controller.close();
},
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ body: stream, ok: true }));
const program = createProgram();
await program.parseAsync(['node', 'test', 'generate', 'text', 'Hi', '--stream']);
const fetchCall = vi.mocked(fetch).mock.calls[0];
const body = JSON.parse(fetchCall[1]!.body as string);
expect(body.stream).toBe(true);
expect(stdoutSpy).toHaveBeenCalledWith('Hello');
});
it('should parse provider from model string', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
json: vi.fn().mockResolvedValue({
choices: [{ message: { content: 'ok' } }],
}),
ok: true,
}),
);
const program = createProgram();
await program.parseAsync([
'node',
'test',
'generate',
'text',
'Hi',
'--model',
'anthropic/claude-3-haiku',
]);
expect(fetch).toHaveBeenCalledWith(
'https://app.lobehub.com/webapi/chat/anthropic',
expect.any(Object),
);
});
it('should exit on error response', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 500,
text: vi.fn().mockResolvedValue('Internal error'),
}),
);
const program = createProgram();
await program.parseAsync(['node', 'test', 'generate', 'text', 'fail']);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('500'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
describe('image', () => {
it('should create image generation', async () => {
mockTrpcClient.generationTopic.createTopic.mutate.mockResolvedValue('topic-1');
mockTrpcClient.image.createImage.mutate.mockResolvedValue({
data: {
batch: { id: 'batch-1' },
generations: [{ asyncTaskId: 'task-1', id: 'gen-1' }],
},
success: true,
});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'generate',
'image',
'a cute cat',
'--model',
'dall-e-3',
'--provider',
'openai',
]);
expect(mockTrpcClient.generationTopic.createTopic.mutate).toHaveBeenCalledWith({
type: 'image',
});
expect(mockTrpcClient.image.createImage.mutate).toHaveBeenCalledWith(
expect.objectContaining({
generationTopicId: 'topic-1',
model: 'dall-e-3',
params: { prompt: 'a cute cat' },
provider: 'openai',
}),
);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Image generation started'));
});
});
describe('video', () => {
it('should create video generation', async () => {
mockTrpcClient.generationTopic.createTopic.mutate.mockResolvedValue('topic-2');
mockTrpcClient.video.createVideo.mutate.mockResolvedValue({
data: { generationId: 'gen-v1' },
success: true,
});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'generate',
'video',
'a dancing cat',
'--model',
'gen-3',
'--provider',
'runway',
]);
expect(mockTrpcClient.video.createVideo.mutate).toHaveBeenCalledWith(
expect.objectContaining({
generationTopicId: 'topic-2',
model: 'gen-3',
params: { prompt: 'a dancing cat' },
provider: 'runway',
}),
);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Video generation started'));
});
});
describe('tts', () => {
it('should call TTS endpoint and save file', async () => {
const audioBuffer = new ArrayBuffer(100);
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
arrayBuffer: vi.fn().mockResolvedValue(audioBuffer),
ok: true,
}),
);
const program = createProgram();
await program.parseAsync([
'node',
'test',
'generate',
'tts',
'Hello world',
'--output',
'/tmp/test.mp3',
]);
expect(fetch).toHaveBeenCalledWith(
'https://app.lobehub.com/webapi/tts/openai',
expect.objectContaining({ method: 'POST' }),
);
expect(mockWriteFileSync).toHaveBeenCalledWith('/tmp/test.mp3', expect.any(Buffer));
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Audio saved'));
});
it('should reject invalid backend', async () => {
const program = createProgram();
await program.parseAsync([
'node',
'test',
'generate',
'tts',
'Hello',
'--backend',
'invalid',
]);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Invalid backend'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
describe('asr', () => {
it('should exit when file not found', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'generate', 'asr', '/nonexistent/audio.mp3']);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('not found'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
describe('status', () => {
it('should show generation status', async () => {
mockTrpcClient.generation.getGenerationStatus.query.mockResolvedValue({
generation: { asset: { url: 'https://example.com/image.png' }, id: 'gen-1' },
status: 'success',
});
const program = createProgram();
await program.parseAsync(['node', 'test', 'generate', 'status', 'gen-1', 'task-1']);
expect(mockTrpcClient.generation.getGenerationStatus.query).toHaveBeenCalledWith({
asyncTaskId: 'task-1',
generationId: 'gen-1',
});
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('success'));
});
});
describe('list', () => {
it('should list generation topics', async () => {
mockTrpcClient.generationTopic.getAllGenerationTopics.query.mockResolvedValue([
{ id: 't1', title: 'My Images', type: 'image', updatedAt: new Date().toISOString() },
]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'generate', 'list']);
expect(consoleSpy).toHaveBeenCalledTimes(2);
expect(consoleSpy.mock.calls[0][0]).toContain('ID');
});
it('should show message when empty', async () => {
mockTrpcClient.generationTopic.getAllGenerationTopics.query.mockResolvedValue([]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'generate', 'list']);
expect(consoleSpy).toHaveBeenCalledWith('No generation topics found.');
});
});
});
+77
View File
@@ -0,0 +1,77 @@
import { createReadStream, existsSync } from 'node:fs';
import path from 'node:path';
import type { Command } from 'commander';
import { getAuthInfo } from '../../api/http';
import { log } from '../../utils/logger';
export function registerAsrCommand(parent: Command) {
parent
.command('asr <audio-file>')
.description('Convert speech to text (automatic speech recognition)')
.option('--model <model>', 'STT model', 'whisper-1')
.option('--language <lang>', 'Language code (e.g. en, zh)')
.option('--json', 'Output raw JSON')
.action(
async (
audioFile: string,
options: {
json?: boolean;
language?: string;
model: string;
},
) => {
if (!existsSync(audioFile)) {
log.error(`File not found: ${audioFile}`);
process.exit(1);
return;
}
const { serverUrl, headers } = await getAuthInfo();
const sttOptions: Record<string, any> = { model: options.model };
if (options.language) sttOptions.language = options.language;
const formData = new FormData();
const fileBuffer = await readFileAsBlob(audioFile);
formData.append('speech', fileBuffer, path.basename(audioFile));
formData.append('options', JSON.stringify(sttOptions));
// Remove Content-Type for multipart/form-data (let fetch set it with boundary)
const { 'Content-Type': _, ...formHeaders } = headers;
const res = await fetch(`${serverUrl}/webapi/stt/openai`, {
body: formData,
headers: formHeaders,
method: 'POST',
});
if (!res.ok) {
const errText = await res.text();
log.error(`ASR failed: ${res.status} ${errText}`);
process.exit(1);
return;
}
const result = await res.json();
if (options.json) {
console.log(JSON.stringify(result, null, 2));
} else {
const text = (result as any).text || JSON.stringify(result);
process.stdout.write(text);
process.stdout.write('\n');
}
},
);
}
async function readFileAsBlob(filePath: string): Promise<Blob> {
const chunks: Uint8Array[] = [];
const stream = createReadStream(filePath);
for await (const chunk of stream) {
chunks.push(chunk as Uint8Array);
}
return new Blob(chunks);
}
+76
View File
@@ -0,0 +1,76 @@
import type { Command } from 'commander';
import pc from 'picocolors';
import { getTrpcClient } from '../../api/client';
export function registerImageCommand(parent: Command) {
parent
.command('image <prompt>')
.description('Generate an image from text')
.option('-m, --model <model>', 'Model ID', 'dall-e-3')
.option('-p, --provider <provider>', 'Provider name', 'openai')
.option('-n, --num <n>', 'Number of images', '1')
.option('--width <px>', 'Width in pixels')
.option('--height <px>', 'Height in pixels')
.option('--steps <n>', 'Number of steps')
.option('--seed <n>', 'Random seed')
.option('--json', 'Output raw JSON')
.action(
async (
prompt: string,
options: {
height?: string;
json?: boolean;
model: string;
num: string;
provider: string;
seed?: string;
steps?: string;
width?: string;
},
) => {
const client = await getTrpcClient();
// Create a generation topic first
const topicId = await client.generationTopic.createTopic.mutate({ type: 'image' });
const params: { prompt: string } & Record<string, any> = { prompt };
if (options.width) params.width = Number.parseInt(options.width, 10);
if (options.height) params.height = Number.parseInt(options.height, 10);
if (options.steps) params.steps = Number.parseInt(options.steps, 10);
if (options.seed) params.seed = Number.parseInt(options.seed, 10);
const result = await client.image.createImage.mutate({
generationTopicId: topicId as string,
imageNum: Number.parseInt(options.num, 10),
model: options.model,
params,
provider: options.provider,
});
const r = result as any;
if (options.json) {
console.log(JSON.stringify(r, null, 2));
return;
}
const data = r.data || r;
console.log(`${pc.green('✓')} Image generation started`);
if (data.batch?.id) console.log(` Batch ID: ${pc.bold(data.batch.id)}`);
const generations = data.generations || [];
if (generations.length > 0) {
console.log(` ${generations.length} image(s) queued`);
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.'),
);
}
},
);
}
+190
View File
@@ -0,0 +1,190 @@
import type { Command } from 'commander';
import pc from 'picocolors';
import { getTrpcClient } from '../../api/client';
import { outputJson, printTable, timeAgo, truncate } from '../../utils/format';
import { registerAsrCommand } from './asr';
import { registerImageCommand } from './image';
import { registerTextCommand } from './text';
import { registerTtsCommand } from './tts';
import { registerVideoCommand } from './video';
export function registerGenerateCommand(program: Command) {
const generate = program
.command('generate')
.alias('gen')
.description('Generate content (text, image, video, speech)');
registerTextCommand(generate);
registerImageCommand(generate);
registerVideoCommand(generate);
registerTtsCommand(generate);
registerAsrCommand(generate);
// ── status ──────────────────────────────────────────
generate
.command('status <generationId> <taskId>')
.description('Check generation task status')
.option('--json', 'Output raw JSON')
.action(async (generationId: string, taskId: string, options: { json?: boolean }) => {
const client = await getTrpcClient();
const result = await client.generation.getGenerationStatus.query({
asyncTaskId: taskId,
generationId,
});
if (options.json) {
console.log(JSON.stringify(result, null, 2));
return;
}
const r = result as any;
console.log(`Status: ${colorStatus(r.status)}`);
if (r.error) {
console.log(`Error: ${pc.red(r.error.message || JSON.stringify(r.error))}`);
}
if (r.generation) {
const gen = r.generation;
console.log(` ID: ${gen.id}`);
if (gen.asset?.url) console.log(` URL: ${gen.asset.url}`);
if (gen.asset?.thumbnailUrl) console.log(` Thumb: ${gen.asset.thumbnailUrl}`);
}
});
// ── 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));
}
},
);
// ── list ────────────────────────────────────────────
generate
.command('list')
.description('List generation topics')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(async (options: { json?: string | boolean }) => {
const client = await getTrpcClient();
const result = await client.generationTopic.getAllGenerationTopics.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 generation topics found.');
return;
}
const rows = items.map((t: any) => [
t.id || '',
truncate(t.title || 'Untitled', 40),
t.type || '',
t.updatedAt ? timeAgo(t.updatedAt) : '',
]);
printTable(rows, ['ID', 'TITLE', 'TYPE', 'UPDATED']);
});
}
export function colorStatus(status: string): string {
switch (status) {
case 'success': {
return pc.green(status);
}
case 'error': {
return pc.red(status);
}
case 'processing': {
return pc.yellow(status);
}
case 'pending': {
return pc.cyan(status);
}
default: {
return status;
}
}
}
+157
View File
@@ -0,0 +1,157 @@
import type { Command } from 'commander';
import { getAuthInfo } from '../../api/http';
import { log } from '../../utils/logger';
export function registerTextCommand(parent: Command) {
parent
.command('text <prompt>')
.description('Generate text with an LLM (single completion, no tools)')
.option('-m, --model <model>', 'Model ID (provider/model format)', 'openai/gpt-4o-mini')
.option('-p, --provider <provider>', 'Provider name (derived from model if omitted)')
.option('-s, --system <prompt>', 'System prompt')
.option('--temperature <n>', 'Temperature (0-2)')
.option('--max-tokens <n>', 'Maximum output tokens')
.option('--stream', 'Enable streaming (SSE, renders incrementally)')
.option('--json', 'Output full JSON response')
.option('--pipe', 'Pipe mode: read additional context from stdin')
.action(
async (
prompt: string,
options: {
json?: boolean;
maxTokens?: string;
model: string;
pipe?: boolean;
provider?: string;
stream?: boolean;
system?: string;
temperature?: string;
},
) => {
// Resolve provider from model if not specified
const parts = options.model.split('/');
const provider = options.provider || (parts.length > 1 ? parts[0] : 'openai');
const model = parts.length > 1 ? parts.slice(1).join('/') : options.model;
// Read additional input from stdin if --pipe
let fullPrompt = prompt;
if (options.pipe) {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(chunk as Buffer);
}
const stdinContent = Buffer.concat(chunks).toString('utf8').trim();
if (stdinContent) {
fullPrompt = `${prompt}\n\n${stdinContent}`;
}
}
const messages: Array<{ content: string; role: string }> = [];
if (options.system) {
messages.push({ content: options.system, role: 'system' });
}
messages.push({ content: fullPrompt, role: 'user' });
const useStream = options.stream === true;
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);
if (options.maxTokens) payload.max_tokens = Number.parseInt(options.maxTokens, 10);
const { serverUrl, headers } = await getAuthInfo();
const res = await fetch(`${serverUrl}/webapi/chat/${provider}`, {
body: JSON.stringify(payload),
headers,
method: 'POST',
});
if (!res.ok) {
const text = await res.text();
log.error(`Text generation failed: ${res.status} ${text}`);
process.exit(1);
return;
}
if (!useStream) {
const body = await res.json();
if (options.json) {
console.log(JSON.stringify(body, null, 2));
} else {
// 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');
}
return;
}
// Stream SSE response
if (!res.body) {
log.error('No response body received');
process.exit(1);
return;
}
await streamSSEResponse(res.body, options.json);
},
);
}
async function streamSSEResponse(body: ReadableStream<Uint8Array>, json?: boolean): Promise<void> {
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data:')) continue;
const data = line.slice(5).trim();
if (data === '[DONE]') {
if (!json) process.stdout.write('\n');
return;
}
try {
const parsed = JSON.parse(data);
if (json) {
console.log(JSON.stringify(parsed));
} 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
if (!json) process.stdout.write(data);
}
}
}
// Final newline
if (!json) process.stdout.write('\n');
} finally {
reader.releaseLock();
}
}
+69
View File
@@ -0,0 +1,69 @@
import { writeFileSync } from 'node:fs';
import type { Command } from 'commander';
import pc from 'picocolors';
import { getAuthInfo } from '../../api/http';
import { log } from '../../utils/logger';
export function registerTtsCommand(parent: Command) {
parent
.command('tts <text>')
.description('Convert text to speech')
.option('-o, --output <file>', 'Output audio file path', 'output.mp3')
.option('--voice <voice>', 'Voice name', 'alloy')
.option('--speed <n>', 'Speed multiplier (0.25-4.0)', '1')
.option('--model <model>', 'TTS model', 'tts-1')
.option('--backend <backend>', 'TTS backend: openai, microsoft, edge', 'openai')
.action(
async (
text: string,
options: {
backend: string;
model: string;
output: string;
speed: string;
voice: string;
},
) => {
const backends = ['openai', 'microsoft', 'edge'];
if (!backends.includes(options.backend)) {
log.error(`Invalid backend. Must be one of: ${backends.join(', ')}`);
process.exit(1);
return;
}
const { serverUrl, headers } = await getAuthInfo();
const payload: Record<string, any> = {
input: text,
model: options.model,
options: {
model: options.model,
voice: options.voice,
},
speed: Number.parseFloat(options.speed),
voice: options.voice,
};
const res = await fetch(`${serverUrl}/webapi/tts/${options.backend}`, {
body: JSON.stringify(payload),
headers,
method: 'POST',
});
if (!res.ok) {
const errText = await res.text();
log.error(`TTS failed: ${res.status} ${errText}`);
process.exit(1);
return;
}
const buffer = Buffer.from(await res.arrayBuffer());
writeFileSync(options.output, buffer);
console.log(
`${pc.green('✓')} Audio saved to ${pc.bold(options.output)} (${Math.round(buffer.length / 1024)}KB)`,
);
},
);
}
+70
View File
@@ -0,0 +1,70 @@
import type { Command } from 'commander';
import pc from 'picocolors';
import { getTrpcClient } from '../../api/client';
export function registerVideoCommand(parent: Command) {
parent
.command('video <prompt>')
.description('Generate a video from text')
.requiredOption('-m, --model <model>', 'Model ID')
.requiredOption('-p, --provider <provider>', 'Provider name')
.option('--aspect-ratio <ratio>', 'Aspect ratio (e.g. 16:9)')
.option('--duration <sec>', 'Duration in seconds')
.option('--resolution <res>', 'Resolution (e.g. 720p, 1080p)')
.option('--seed <n>', 'Random seed')
.option('--json', 'Output raw JSON')
.action(
async (
prompt: string,
options: {
aspectRatio?: string;
duration?: string;
json?: boolean;
model: string;
provider: string;
resolution?: string;
seed?: string;
},
) => {
const client = await getTrpcClient();
const topicId = await client.generationTopic.createTopic.mutate({ type: 'video' });
const params: { prompt: string } & Record<string, any> = { prompt };
if (options.aspectRatio) params.aspectRatio = options.aspectRatio;
if (options.duration) params.duration = Number.parseInt(options.duration, 10);
if (options.resolution) params.resolution = options.resolution;
if (options.seed) params.seed = Number.parseInt(options.seed, 10);
const result = await client.video.createVideo.mutate({
generationTopicId: topicId as string,
model: options.model,
params,
provider: options.provider,
});
const r = result as any;
if (options.json) {
console.log(JSON.stringify(r, null, 2));
return;
}
const data = r.data || r;
console.log(`${pc.green('✓')} Video generation started`);
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.'),
);
}
},
);
}
+213
View File
@@ -0,0 +1,213 @@
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { log } from '../utils/logger';
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() },
getKnowledgeBaseById: { query: vi.fn() },
getKnowledgeBases: { query: vi.fn() },
removeFilesFromKnowledgeBase: { mutate: vi.fn() },
removeKnowledgeBase: { mutate: vi.fn() },
updateKnowledgeBase: { 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('kb 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);
// Reset all mocks
for (const router of Object.values(mockTrpcClient)) {
for (const method of Object.values(router)) {
for (const fn of Object.values(method)) {
(fn as ReturnType<typeof vi.fn>).mockReset();
}
}
}
// Default: file queries return empty
mockTrpcClient.file.getFiles.query.mockResolvedValue([]);
mockTrpcClient.file.getKnowledgeItems.query.mockResolvedValue({ hasMore: false, items: [] });
});
afterEach(() => {
exitSpy.mockRestore();
consoleSpy.mockRestore();
});
function createProgram() {
const program = new Command();
program.exitOverride();
registerKbCommand(program);
return program;
}
describe('list', () => {
it('should display knowledge bases in table format', async () => {
mockTrpcClient.knowledgeBase.getKnowledgeBases.query.mockResolvedValue([
{ description: 'My KB', id: 'kb1', name: 'Test KB', updatedAt: new Date().toISOString() },
]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'kb', 'list']);
expect(consoleSpy).toHaveBeenCalledTimes(2); // header + 1 row
expect(consoleSpy.mock.calls[0][0]).toContain('ID');
});
it('should output JSON when --json flag is used', async () => {
const items = [{ id: 'kb1', name: 'Test' }];
mockTrpcClient.knowledgeBase.getKnowledgeBases.query.mockResolvedValue(items);
const program = createProgram();
await program.parseAsync(['node', 'test', 'kb', 'list', '--json']);
expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(items, null, 2));
});
it('should show message when no knowledge bases found', async () => {
mockTrpcClient.knowledgeBase.getKnowledgeBases.query.mockResolvedValue([]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'kb', 'list']);
expect(consoleSpy).toHaveBeenCalledWith('No knowledge bases found.');
});
});
describe('view', () => {
it('should display knowledge base details', async () => {
mockTrpcClient.knowledgeBase.getKnowledgeBaseById.query.mockResolvedValue({
description: 'A test KB',
id: 'kb1',
name: 'Test KB',
});
const program = createProgram();
await program.parseAsync(['node', 'test', 'kb', 'view', 'kb1']);
expect(mockTrpcClient.knowledgeBase.getKnowledgeBaseById.query).toHaveBeenCalledWith({
id: 'kb1',
});
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Test KB'));
});
it('should exit when not found', async () => {
mockTrpcClient.knowledgeBase.getKnowledgeBaseById.query.mockResolvedValue(null);
const program = createProgram();
await program.parseAsync(['node', 'test', 'kb', 'view', 'nonexistent']);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('not found'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
describe('create', () => {
it('should create a knowledge base', async () => {
mockTrpcClient.knowledgeBase.createKnowledgeBase.mutate.mockResolvedValue('kb-new');
const program = createProgram();
await program.parseAsync([
'node',
'test',
'kb',
'create',
'--name',
'New KB',
'--description',
'Test desc',
]);
expect(mockTrpcClient.knowledgeBase.createKnowledgeBase.mutate).toHaveBeenCalledWith(
expect.objectContaining({ description: 'Test desc', name: 'New KB' }),
);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('kb-new'));
});
});
describe('edit', () => {
it('should update knowledge base', async () => {
mockTrpcClient.knowledgeBase.updateKnowledgeBase.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync(['node', 'test', 'kb', 'edit', 'kb1', '--name', 'Updated']);
expect(mockTrpcClient.knowledgeBase.updateKnowledgeBase.mutate).toHaveBeenCalledWith({
id: 'kb1',
value: { name: 'Updated' },
});
});
it('should exit when no changes specified', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'kb', 'edit', 'kb1']);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('No changes'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
describe('delete', () => {
it('should delete with --yes', async () => {
mockTrpcClient.knowledgeBase.removeKnowledgeBase.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync(['node', 'test', 'kb', 'delete', 'kb1', '--yes']);
expect(mockTrpcClient.knowledgeBase.removeKnowledgeBase.mutate).toHaveBeenCalledWith({
id: 'kb1',
removeFiles: undefined,
});
});
it('should pass --remove-files flag', async () => {
mockTrpcClient.knowledgeBase.removeKnowledgeBase.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync(['node', 'test', 'kb', 'delete', 'kb1', '--yes', '--remove-files']);
expect(mockTrpcClient.knowledgeBase.removeKnowledgeBase.mutate).toHaveBeenCalledWith({
id: 'kb1',
removeFiles: true,
});
});
});
describe('add-files', () => {
it('should add files to knowledge base', async () => {
mockTrpcClient.knowledgeBase.addFilesToKnowledgeBase.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync(['node', 'test', 'kb', 'add-files', 'kb1', '--ids', 'f1', 'f2']);
expect(mockTrpcClient.knowledgeBase.addFilesToKnowledgeBase.mutate).toHaveBeenCalledWith({
ids: ['f1', 'f2'],
knowledgeBaseId: 'kb1',
});
});
});
});
+404
View File
@@ -0,0 +1,404 @@
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, folders, documents, and files');
// ── list ──────────────────────────────────────────────
kb.command('list')
.description('List knowledge bases')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(async (options: { json?: string | boolean }) => {
const client = await getTrpcClient();
const result = await client.knowledgeBase.getKnowledgeBases.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 knowledge bases found.');
return;
}
const rows = items.map((kb: any) => [
kb.id,
truncate(kb.name || 'Untitled', 40),
truncate(kb.description || '', 50),
kb.updatedAt ? timeAgo(kb.updatedAt) : '',
]);
printTable(rows, ['ID', 'NAME', 'DESCRIPTION', 'UPDATED']);
});
// ── view ──────────────────────────────────────────────
kb.command('view <id>')
.description('View a knowledge base')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(async (id: string, options: { json?: string | boolean }) => {
const client = await getTrpcClient();
const result = await client.knowledgeBase.getKnowledgeBaseById.query({ id });
if (!result) {
log.error(`Knowledge base not found: ${id}`);
process.exit(1);
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, files: allItems }, fields);
return;
}
console.log(pc.bold(result.name || 'Untitled'));
const meta: string[] = [];
if (result.description) meta.push(result.description);
if ((result as any).updatedAt) meta.push(`Updated ${timeAgo((result as any).updatedAt)}`);
if (meta.length > 0) console.log(pc.dim(meta.join(' · ')));
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,
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.'));
}
});
// ── create ────────────────────────────────────────────
kb.command('create')
.description('Create a knowledge base')
.requiredOption('-n, --name <name>', 'Knowledge base name')
.option('-d, --description <desc>', 'Description')
.option('--avatar <url>', 'Avatar URL')
.action(async (options: { avatar?: string; description?: string; name: string }) => {
const client = await getTrpcClient();
const input: { avatar?: string; description?: string; name: string } = {
name: options.name,
};
if (options.description) input.description = options.description;
if (options.avatar) input.avatar = options.avatar;
const id = await client.knowledgeBase.createKnowledgeBase.mutate(input);
console.log(`${pc.green('✓')} Created knowledge base ${pc.bold(String(id))}`);
});
// ── edit ──────────────────────────────────────────────
kb.command('edit <id>')
.description('Update a knowledge base')
.option('-n, --name <name>', 'New name')
.option('-d, --description <desc>', 'New description')
.option('--avatar <url>', 'New avatar URL')
.action(
async (id: string, options: { avatar?: string; description?: string; name?: string }) => {
if (!options.name && !options.description && !options.avatar) {
log.error('No changes specified. Use --name, --description, or --avatar.');
process.exit(1);
}
const client = await getTrpcClient();
const value: Record<string, any> = {};
if (options.name) value.name = options.name;
if (options.description) value.description = options.description;
if (options.avatar) value.avatar = options.avatar;
await client.knowledgeBase.updateKnowledgeBase.mutate({ id, value });
console.log(`${pc.green('✓')} Updated knowledge base ${pc.bold(id)}`);
},
);
// ── delete ────────────────────────────────────────────
kb.command('delete <id>')
.description('Delete a knowledge base')
.option('--remove-files', 'Also delete associated files')
.option('--yes', 'Skip confirmation prompt')
.action(async (id: string, options: { removeFiles?: boolean; yes?: boolean }) => {
if (!options.yes) {
const confirmed = await confirm('Are you sure you want to delete this knowledge base?');
if (!confirmed) {
console.log('Cancelled.');
return;
}
}
const client = await getTrpcClient();
await client.knowledgeBase.removeKnowledgeBase.mutate({
id,
removeFiles: options.removeFiles,
});
console.log(`${pc.green('✓')} Deleted knowledge base ${pc.bold(id)}`);
});
// ── add-files ─────────────────────────────────────────
kb.command('add-files <knowledgeBaseId>')
.description('Add files to a knowledge base')
.requiredOption('--ids <ids...>', 'File IDs to add')
.action(async (knowledgeBaseId: string, options: { ids: string[] }) => {
const client = await getTrpcClient();
await client.knowledgeBase.addFilesToKnowledgeBase.mutate({
ids: options.ids,
knowledgeBaseId,
});
console.log(
`${pc.green('✓')} Added ${options.ids.length} file(s) to knowledge base ${pc.bold(knowledgeBaseId)}`,
);
});
// ── remove-files ──────────────────────────────────────
kb.command('remove-files <knowledgeBaseId>')
.description('Remove files from a knowledge base')
.requiredOption('--ids <ids...>', 'File IDs to remove')
.option('--yes', 'Skip confirmation prompt')
.action(async (knowledgeBaseId: string, options: { ids: string[]; yes?: boolean }) => {
if (!options.yes) {
const confirmed = await confirm(
`Remove ${options.ids.length} file(s) from knowledge base?`,
);
if (!confirmed) {
console.log('Cancelled.');
return;
}
}
const client = await getTrpcClient();
await client.knowledgeBase.removeFilesFromKnowledgeBase.mutate({
ids: options.ids,
knowledgeBaseId,
});
console.log(
`${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)}`,
);
});
}
+316
View File
@@ -0,0 +1,316 @@
import fs from 'node:fs';
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { saveCredentials } from '../auth/credentials';
import { loadSettings, saveSettings } from '../settings';
import { log } from '../utils/logger';
import { registerLoginCommand, resolveCommandExecutable } from './login';
vi.mock('../auth/credentials', () => ({
saveCredentials: vi.fn(),
}));
vi.mock('../settings', () => ({
loadSettings: vi.fn().mockReturnValue(null),
saveSettings: vi.fn(),
}));
vi.mock('../utils/logger', () => ({
log: {
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
},
}));
// Mock child_process to prevent browser opening
vi.mock('node:child_process', () => ({
default: {
exec: vi.fn((_cmd: string, cb: any) => cb?.(null)),
execFile: vi.fn((_cmd: string, _args: string[], cb: any) => cb?.(null)),
},
exec: vi.fn((_cmd: string, cb: any) => cb?.(null)),
execFile: vi.fn((_cmd: string, _args: string[], cb: any) => cb?.(null)),
}));
describe('login command', () => {
let exitSpy: ReturnType<typeof vi.spyOn>;
const originalPath = process.env.PATH;
const originalPathext = process.env.PATHEXT;
const originalSystemRoot = process.env.SystemRoot;
beforeEach(() => {
vi.useFakeTimers();
vi.stubGlobal('fetch', vi.fn());
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
vi.mocked(loadSettings).mockReturnValue(null);
});
afterEach(() => {
vi.useRealTimers();
exitSpy.mockRestore();
process.env.PATH = originalPath;
process.env.PATHEXT = originalPathext;
process.env.SystemRoot = originalSystemRoot;
vi.restoreAllMocks();
});
function createProgram() {
const program = new Command();
program.exitOverride();
registerLoginCommand(program);
return program;
}
function deviceAuthResponse(overrides: Record<string, any> = {}) {
return {
json: vi.fn().mockResolvedValue({
device_code: 'device-123',
expires_in: 600,
interval: 1,
user_code: 'USER-CODE',
verification_uri: 'https://app.lobehub.com/verify',
verification_uri_complete: 'https://app.lobehub.com/verify?code=USER-CODE',
...overrides,
}),
ok: true,
} as any;
}
function tokenSuccessResponse(overrides: Record<string, any> = {}) {
return {
json: vi.fn().mockResolvedValue({
access_token: 'new-token',
expires_in: 3600,
refresh_token: 'refresh-tok',
token_type: 'Bearer',
...overrides,
}),
ok: true,
} as any;
}
function tokenErrorResponse(error: string, description?: string) {
return {
json: vi.fn().mockResolvedValue({
error,
error_description: description,
}),
ok: true,
} as any;
}
async function runLoginAndAdvanceTimers(program: Command, args: string[] = []) {
const parsePromise = program.parseAsync(['node', 'test', 'login', ...args]);
// Advance timers to let sleep resolve in the polling loop
for (let i = 0; i < 10; i++) {
await vi.advanceTimersByTimeAsync(2000);
}
return parsePromise;
}
it('should complete login flow successfully', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(deviceAuthResponse())
.mockResolvedValueOnce(tokenErrorResponse('authorization_pending'))
.mockResolvedValueOnce(tokenSuccessResponse());
const program = createProgram();
await runLoginAndAdvanceTimers(program);
expect(saveCredentials).toHaveBeenCalledWith(
expect.objectContaining({
accessToken: 'new-token',
refreshToken: 'refresh-tok',
}),
);
expect(saveSettings).toHaveBeenCalledWith({ serverUrl: 'https://app.lobehub.com' });
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Login successful'));
});
it('should persist custom server into settings', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(deviceAuthResponse())
.mockResolvedValueOnce(tokenSuccessResponse());
const program = createProgram();
await runLoginAndAdvanceTimers(program, ['--server', 'https://test.com/']);
expect(saveSettings).toHaveBeenCalledWith({ serverUrl: 'https://test.com' });
});
it('should preserve existing gateway when logging into the same server', async () => {
vi.mocked(loadSettings).mockReturnValueOnce({
gatewayUrl: 'https://gateway.example.com',
serverUrl: 'https://test.com',
});
vi.mocked(fetch)
.mockResolvedValueOnce(deviceAuthResponse())
.mockResolvedValueOnce(tokenSuccessResponse());
const program = createProgram();
await runLoginAndAdvanceTimers(program, ['--server', 'https://test.com/']);
expect(saveSettings).toHaveBeenCalledWith({
gatewayUrl: 'https://gateway.example.com',
serverUrl: 'https://test.com',
});
});
it('should clear existing gateway when logging into a different server', async () => {
vi.mocked(loadSettings).mockReturnValueOnce({
gatewayUrl: 'https://gateway.example.com',
serverUrl: 'https://old.example.com',
});
vi.mocked(fetch)
.mockResolvedValueOnce(deviceAuthResponse())
.mockResolvedValueOnce(tokenSuccessResponse());
const program = createProgram();
await runLoginAndAdvanceTimers(program, ['--server', 'https://new.example.com/']);
expect(saveSettings).toHaveBeenCalledWith({ serverUrl: 'https://new.example.com' });
});
it('should strip trailing slash from server URL', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(deviceAuthResponse())
.mockResolvedValueOnce(tokenSuccessResponse());
const program = createProgram();
await runLoginAndAdvanceTimers(program, ['--server', 'https://test.com/']);
expect(fetch).toHaveBeenCalledWith('https://test.com/oidc/device/auth', expect.any(Object));
});
it('should handle device auth failure', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
status: 500,
text: vi.fn().mockResolvedValue('Server Error'),
} as any);
const program = createProgram();
await runLoginAndAdvanceTimers(program);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Failed to start'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should handle network error on device auth', async () => {
vi.mocked(fetch).mockRejectedValueOnce(new Error('ECONNREFUSED'));
const program = createProgram();
await runLoginAndAdvanceTimers(program);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Failed to reach'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should handle access_denied error', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(deviceAuthResponse({ expires_in: 2 }))
.mockResolvedValueOnce(tokenErrorResponse('access_denied'));
const program = createProgram();
await runLoginAndAdvanceTimers(program);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('denied'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should handle expired_token error', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(deviceAuthResponse({ expires_in: 2 }))
.mockResolvedValueOnce(tokenErrorResponse('expired_token'));
const program = createProgram();
await runLoginAndAdvanceTimers(program);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('expired'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should handle slow_down by increasing interval', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(deviceAuthResponse())
.mockResolvedValueOnce(tokenErrorResponse('slow_down'))
.mockResolvedValueOnce(tokenSuccessResponse());
const program = createProgram();
await runLoginAndAdvanceTimers(program);
expect(saveCredentials).toHaveBeenCalled();
});
it('should handle unknown error', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(deviceAuthResponse({ expires_in: 2 }))
.mockResolvedValueOnce(tokenErrorResponse('server_error', 'Something went wrong'));
const program = createProgram();
await runLoginAndAdvanceTimers(program);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('server_error'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should handle network error during polling', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(deviceAuthResponse())
.mockRejectedValueOnce(new Error('network'))
.mockResolvedValueOnce(tokenSuccessResponse());
const program = createProgram();
await runLoginAndAdvanceTimers(program);
expect(saveCredentials).toHaveBeenCalled();
});
it('should handle token without expires_in', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(deviceAuthResponse())
.mockResolvedValueOnce(tokenSuccessResponse({ expires_in: undefined }));
const program = createProgram();
await runLoginAndAdvanceTimers(program);
expect(saveCredentials).toHaveBeenCalledWith(expect.objectContaining({ expiresAt: undefined }));
});
it('should use default interval when not provided', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(deviceAuthResponse({ interval: undefined }))
.mockResolvedValueOnce(tokenSuccessResponse());
const program = createProgram();
await runLoginAndAdvanceTimers(program);
expect(saveCredentials).toHaveBeenCalled();
});
it('should handle device code expiration during polling', async () => {
vi.mocked(fetch).mockResolvedValueOnce(deviceAuthResponse({ expires_in: 0 }));
const program = createProgram();
await runLoginAndAdvanceTimers(program);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('expired'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should resolve Windows executable via PATHEXT', () => {
process.env.PATH = 'C:\\Tools';
process.env.PATHEXT = '.EXE;.CMD';
process.env.SystemRoot = 'C:\\Windows';
vi.spyOn(fs, 'existsSync').mockImplementation(
(targetPath) => String(targetPath).toLowerCase() === 'c:\\tools\\rundll32.exe',
);
const resolved = resolveCommandExecutable('rundll32', 'win32');
expect(resolved?.toLowerCase()).toBe('c:\\tools\\rundll32.exe');
});
});
+277
View File
@@ -0,0 +1,277 @@
import { execFile } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import type { Command } from 'commander';
import { saveCredentials } from '../auth/credentials';
import { OFFICIAL_SERVER_URL } from '../constants/urls';
import { loadSettings, saveSettings } from '../settings';
import { log } from '../utils/logger';
const CLIENT_ID = 'lobehub-cli';
const SCOPES = 'openid profile email offline_access';
interface LoginOptions {
server: string;
}
interface DeviceAuthResponse {
device_code: string;
expires_in: number;
interval: number;
user_code: string;
verification_uri: string;
verification_uri_complete?: string;
}
interface TokenResponse {
access_token: string;
expires_in?: number;
refresh_token?: string;
token_type: string;
}
interface TokenErrorResponse {
error: string;
error_description?: string;
}
async function parseJsonResponse<T>(res: Response, endpoint: string): Promise<T> {
try {
return (await res.json()) as T;
} catch {
const contentType = res.headers.get('content-type') || 'unknown';
throw new Error(
`Expected JSON from ${endpoint}, got non-JSON response (status=${res.status}, content-type=${contentType}).`,
);
}
}
export function registerLoginCommand(program: Command) {
program
.command('login')
.description('Log in to LobeHub via browser (Device Code Flow)')
.option('--server <url>', 'LobeHub server URL', OFFICIAL_SERVER_URL)
.action(async (options: LoginOptions) => {
const serverUrl = options.server.replace(/\/$/, '');
log.info('Starting login...');
// Step 1: Request device code
let deviceAuth: DeviceAuthResponse;
try {
const res = await fetch(`${serverUrl}/oidc/device/auth`, {
body: new URLSearchParams({
client_id: CLIENT_ID,
resource: 'urn:lobehub:chat',
scope: SCOPES,
}),
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
method: 'POST',
});
if (!res.ok) {
const text = await res.text();
log.error(`Failed to start device authorization: ${res.status} ${text}`);
process.exit(1);
return;
}
deviceAuth = await parseJsonResponse<DeviceAuthResponse>(res, '/oidc/device/auth');
} catch (error: any) {
log.error(`Failed to reach server: ${error.message}`);
log.error(`Make sure ${serverUrl} is reachable.`);
process.exit(1);
return;
}
// Step 2: Show user code and open browser
const verifyUrl = deviceAuth.verification_uri_complete || deviceAuth.verification_uri;
log.info('');
log.info(' Open this URL in your browser:');
log.info(` ${verifyUrl}`);
log.info('');
log.info(` Enter code: ${deviceAuth.user_code}`);
log.info('');
// Try to open browser automatically
const opened = await openBrowser(verifyUrl);
if (!opened) {
log.warn('Could not open browser automatically.');
}
log.info('Waiting for authorization...');
// Step 3: Poll for token
const interval = (deviceAuth.interval || 5) * 1000;
const expiresAt = Date.now() + deviceAuth.expires_in * 1000;
let pollInterval = interval;
while (Date.now() < expiresAt) {
await sleep(pollInterval);
try {
const res = await fetch(`${serverUrl}/oidc/token`, {
body: new URLSearchParams({
client_id: CLIENT_ID,
device_code: deviceAuth.device_code,
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
}),
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
method: 'POST',
});
const body = await parseJsonResponse<TokenResponse & TokenErrorResponse>(
res,
'/oidc/token',
);
// Check body for error field — some proxies may return 200 for error responses
if (body.error) {
switch (body.error) {
case 'authorization_pending': {
// Keep polling
break;
}
case 'slow_down': {
pollInterval += 5000;
break;
}
case 'access_denied': {
log.error('Authorization denied by user.');
process.exit(1);
return;
}
case 'expired_token': {
log.error('Device code expired. Please run login again.');
process.exit(1);
return;
}
default: {
log.error(`Authorization error: ${body.error} - ${body.error_description || ''}`);
process.exit(1);
return;
}
}
} else if (body.access_token) {
saveCredentials({
accessToken: body.access_token,
expiresAt: body.expires_in
? Math.floor(Date.now() / 1000) + body.expires_in
: undefined,
refreshToken: body.refresh_token,
});
const existingSettings = loadSettings();
const shouldPreserveGateway = existingSettings?.serverUrl === serverUrl;
saveSettings(
shouldPreserveGateway
? {
gatewayUrl: existingSettings.gatewayUrl,
serverUrl,
}
: {
// Gateway auth is tied to the login server's token issuer/JWKS.
// When server changes, clear old gateway to avoid stale cross-environment config.
serverUrl,
},
);
log.info('Login successful! Credentials saved.');
return;
}
} catch {
// Network error — keep retrying
}
}
log.error('Device code expired. Please run login again.');
process.exit(1);
});
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function resolveCommandExecutable(
cmd: string,
platform: NodeJS.Platform = process.platform,
): string | undefined {
if (!cmd) return undefined;
// If command already contains a path, only check that exact location.
if (cmd.includes('/') || cmd.includes('\\')) {
return fs.existsSync(cmd) ? cmd : undefined;
}
const pathValue = process.env.PATH || '';
if (!pathValue) return undefined;
if (platform === 'win32') {
const pathEntries = pathValue.split(';').filter(Boolean);
const pathext = (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean);
const hasExtension = path.win32.extname(cmd).length > 0;
const candidateNames = hasExtension ? [cmd] : [cmd, ...pathext.map((ext) => `${cmd}${ext}`)];
// Prefer PATH lookup, then fall back to System32 for built-in tools like rundll32.
const systemRoot = process.env.SystemRoot || process.env.WINDIR;
if (systemRoot) {
pathEntries.push(path.win32.join(systemRoot, 'System32'));
}
for (const entry of pathEntries) {
for (const candidate of candidateNames) {
const resolved = path.win32.join(entry, candidate);
if (fs.existsSync(resolved)) return resolved;
}
}
return undefined;
}
const pathEntries = pathValue.split(path.delimiter).filter(Boolean);
for (const entry of pathEntries) {
const resolved = path.join(entry, cmd);
if (fs.existsSync(resolved)) return resolved;
}
return undefined;
}
async function openBrowser(url: string): Promise<boolean> {
const runCommand = (cmd: string, args: string[]) =>
new Promise<boolean>((resolve) => {
const executable = resolveCommandExecutable(cmd);
if (!executable) {
log.debug(`Could not open browser automatically: command not found in PATH: ${cmd}`);
resolve(false);
return;
}
try {
execFile(executable, args, (err) => {
if (err) {
log.debug(`Could not open browser automatically: ${err.message}`);
resolve(false);
return;
}
resolve(true);
});
} catch (error: any) {
log.debug(`Could not open browser automatically: ${error?.message || String(error)}`);
resolve(false);
}
});
if (process.platform === 'win32') {
// On Windows, use rundll32 to invoke the default URL handler without a shell.
return runCommand('rundll32', ['url.dll,FileProtocolHandler', url]);
}
const cmd = process.platform === 'darwin' ? 'open' : 'xdg-open';
return runCommand(cmd, [url]);
}
+47
View File
@@ -0,0 +1,47 @@
import { Command } from 'commander';
import { describe, expect, it, vi } from 'vitest';
import { clearCredentials } from '../auth/credentials';
import { log } from '../utils/logger';
import { registerLogoutCommand } from './logout';
vi.mock('../auth/credentials', () => ({
clearCredentials: vi.fn(),
}));
vi.mock('../utils/logger', () => ({
log: {
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
},
}));
describe('logout command', () => {
function createProgram() {
const program = new Command();
program.exitOverride();
registerLogoutCommand(program);
return program;
}
it('should log success when credentials are removed', async () => {
vi.mocked(clearCredentials).mockReturnValue(true);
const program = createProgram();
await program.parseAsync(['node', 'test', 'logout']);
expect(clearCredentials).toHaveBeenCalled();
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Logged out'));
});
it('should log already logged out when no credentials', async () => {
vi.mocked(clearCredentials).mockReturnValue(false);
const program = createProgram();
await program.parseAsync(['node', 'test', 'logout']);
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Already logged out'));
});
});
+18
View File
@@ -0,0 +1,18 @@
import type { Command } from 'commander';
import { clearCredentials } from '../auth/credentials';
import { log } from '../utils/logger';
export function registerLogoutCommand(program: Command) {
program
.command('logout')
.description('Log out and remove stored credentials')
.action(() => {
const removed = clearCredentials();
if (removed) {
log.info('Logged out. Credentials removed.');
} else {
log.info('No credentials found. Already logged out.');
}
});
}
+215
View File
@@ -0,0 +1,215 @@
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { log } from '../utils/logger';
import { registerMemoryCommand } from './memory';
const { mockTrpcClient } = vi.hoisted(() => ({
mockTrpcClient: {
userMemory: {
createIdentity: { mutate: vi.fn() },
deleteIdentity: { mutate: vi.fn() },
getActivities: { query: vi.fn() },
getContexts: { query: vi.fn() },
getExperiences: { query: vi.fn() },
getIdentities: { query: vi.fn() },
getMemoryExtractionTask: { query: vi.fn() },
getPersona: { query: vi.fn() },
getPreferences: { query: vi.fn() },
requestMemoryFromChatTopic: { mutate: vi.fn() },
updateIdentity: { 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('memory 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.userMemory)) {
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();
registerMemoryCommand(program);
return program;
}
describe('list', () => {
it('should list all categories when no category specified', async () => {
mockTrpcClient.userMemory.getIdentities.query.mockResolvedValue([
{ description: 'Dev', id: '1', type: 'professional' },
]);
mockTrpcClient.userMemory.getActivities.query.mockResolvedValue([]);
mockTrpcClient.userMemory.getContexts.query.mockResolvedValue([]);
mockTrpcClient.userMemory.getExperiences.query.mockResolvedValue([]);
mockTrpcClient.userMemory.getPreferences.query.mockResolvedValue([]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'memory', 'list']);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Identity'));
});
it('should list specific category', async () => {
mockTrpcClient.userMemory.getIdentities.query.mockResolvedValue([
{ description: 'Dev', id: '1', type: 'professional' },
]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'memory', 'list', 'identity']);
expect(mockTrpcClient.userMemory.getIdentities.query).toHaveBeenCalled();
});
it('should output JSON', async () => {
const items = [{ id: '1', type: 'professional' }];
mockTrpcClient.userMemory.getIdentities.query.mockResolvedValue(items);
const program = createProgram();
await program.parseAsync(['node', 'test', 'memory', 'list', 'identity', '--json']);
expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(items, null, 2));
});
it('should reject invalid category', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'memory', 'list', 'invalid']);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Invalid category'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
describe('create', () => {
it('should create an identity memory', async () => {
mockTrpcClient.userMemory.createIdentity.mutate.mockResolvedValue({ id: 'mem-1' });
const program = createProgram();
await program.parseAsync([
'node',
'test',
'memory',
'create',
'--type',
'professional',
'--description',
'Software dev',
]);
expect(mockTrpcClient.userMemory.createIdentity.mutate).toHaveBeenCalledWith(
expect.objectContaining({ description: 'Software dev', type: 'professional' }),
);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('mem-1'));
});
});
describe('edit', () => {
it('should update an identity memory', async () => {
mockTrpcClient.userMemory.updateIdentity.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'memory',
'edit',
'identity',
'mem-1',
'--description',
'Updated desc',
]);
expect(mockTrpcClient.userMemory.updateIdentity.mutate).toHaveBeenCalledWith({
data: { description: 'Updated desc' },
id: 'mem-1',
});
});
});
describe('delete', () => {
it('should delete a memory with --yes', async () => {
mockTrpcClient.userMemory.deleteIdentity.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync(['node', 'test', 'memory', 'delete', 'identity', 'mem-1', '--yes']);
expect(mockTrpcClient.userMemory.deleteIdentity.mutate).toHaveBeenCalledWith({
id: 'mem-1',
});
});
});
describe('persona', () => {
it('should display persona', async () => {
mockTrpcClient.userMemory.getPersona.query.mockResolvedValue('You are a developer.');
const program = createProgram();
await program.parseAsync(['node', 'test', 'memory', 'persona']);
expect(consoleSpy).toHaveBeenCalledWith('You are a developer.');
});
it('should output JSON', async () => {
const persona = { summary: 'Developer' };
mockTrpcClient.userMemory.getPersona.query.mockResolvedValue(persona);
const program = createProgram();
await program.parseAsync(['node', 'test', 'memory', 'persona', '--json']);
expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(persona, null, 2));
});
});
describe('extract', () => {
it('should start memory extraction', async () => {
mockTrpcClient.userMemory.requestMemoryFromChatTopic.mutate.mockResolvedValue({
id: 'task-1',
});
const program = createProgram();
await program.parseAsync(['node', 'test', 'memory', 'extract']);
expect(mockTrpcClient.userMemory.requestMemoryFromChatTopic.mutate).toHaveBeenCalled();
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('extraction started'));
});
});
describe('extract-status', () => {
it('should show extraction task status', async () => {
mockTrpcClient.userMemory.getMemoryExtractionTask.query.mockResolvedValue({
id: 'task-1',
status: 'completed',
});
const program = createProgram();
await program.parseAsync(['node', 'test', 'memory', 'extract-status']);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('task-1'));
});
});
});
+335
View File
@@ -0,0 +1,335 @@
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';
// ── Memory Categories ───────────────────────────────────────
const CATEGORIES = ['identity', 'activity', 'context', 'experience', 'preference'] as const;
type Category = (typeof CATEGORIES)[number];
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
export function registerMemoryCommand(program: Command) {
const memory = program.command('memory').description('Manage user memories');
// ── list ──────────────────────────────────────────────
memory
.command('list')
.description('List memories by category')
.argument('[category]', `Memory category: ${CATEGORIES.join(', ')} (default: all)`)
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(async (category: string | undefined, options: { json?: string | boolean }) => {
if (category && !CATEGORIES.includes(category as Category)) {
log.error(`Invalid category: ${category}. Must be one of: ${CATEGORIES.join(', ')}`);
process.exit(1);
}
const client = await getTrpcClient();
const categoriesToFetch = category ? [category as Category] : [...CATEGORIES];
const allResults: Record<string, any[]> = {};
for (const cat of categoriesToFetch) {
try {
allResults[cat] = await fetchCategory(client, cat);
} catch {
allResults[cat] = [];
}
}
if (options.json !== undefined) {
const fields = typeof options.json === 'string' ? options.json : undefined;
outputJson(category ? allResults[category] : allResults, fields);
return;
}
for (const [cat, items] of Object.entries(allResults)) {
if (!Array.isArray(items) || items.length === 0) {
if (category) console.log(`No ${cat} memories found.`);
continue;
}
console.log();
console.log(pc.bold(pc.cyan(`── ${capitalize(cat)} (${items.length}) ──`)));
const rows = items.map((item: any) => {
const desc =
item.description ||
item.narrative ||
item.title ||
item.situation ||
item.conclusionDirectives ||
item.content ||
'';
return [
item.id || '',
truncate(item.type || item.role || item.status || '', 20),
truncate(desc, 60),
];
});
printTable(rows, ['ID', 'TYPE/STATUS', 'DESCRIPTION']);
}
});
// ── create ────────────────────────────────────────────
memory
.command('create')
.description('Create an identity memory entry (other categories are created via extraction)')
.option('--type <type>', 'Memory type')
.option('--role <role>', 'Role')
.option('--relationship <rel>', 'Relationship')
.option('-d, --description <desc>', 'Description')
.option('--labels <labels...>', 'Extracted labels')
.action(async (options: Record<string, any>) => {
const client = await getTrpcClient();
const input: Record<string, any> = {};
if (options.type) input.type = options.type;
if (options.role) input.role = options.role;
if (options.relationship) input.relationship = options.relationship;
if (options.description) input.description = options.description;
if (options.labels) input.extractedLabels = options.labels;
try {
const result = await (client.userMemory as any).createIdentity.mutate(input);
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);
return;
}
});
// ── edit ──────────────────────────────────────────────
memory
.command('edit <category> <id>')
.description(`Update a memory entry (${CATEGORIES.join(', ')})`)
.option('--type <type>', 'Memory type (for identity)')
.option('--role <role>', 'Role (for identity)')
.option('--relationship <rel>', 'Relationship (for identity)')
.option('-d, --description <desc>', 'Description')
.option('--narrative <text>', 'Narrative (for activity)')
.option('--notes <text>', 'Notes (for activity)')
.option('--status <status>', 'Status (for activity/context)')
.option('--title <title>', 'Title (for context)')
.option('--situation <text>', 'Situation (for experience)')
.option('--action <text>', 'Action (for experience)')
.option('--key-learning <text>', 'Key learning (for experience)')
.option('--directives <text>', 'Conclusion directives (for preference)')
.option('--suggestions <text>', 'Suggestions (for preference)')
.option('--labels <labels...>', 'Extracted labels')
.action(async (category: string, id: string, options: Record<string, any>) => {
if (!CATEGORIES.includes(category as Category)) {
log.error(`Invalid category: ${category}. Must be one of: ${CATEGORIES.join(', ')}`);
process.exit(1);
}
const client = await getTrpcClient();
const router = client.userMemory as any;
const mutationName = `update${capitalize(category)}`;
const data = buildCategoryInput(category as Category, options);
try {
await router[mutationName].mutate({ data, id });
console.log(`${pc.green('✓')} Updated ${category} memory ${pc.bold(id)}`);
} catch (error: any) {
log.error(`Failed to update ${category}: ${error.message}`);
process.exit(1);
return;
}
});
// ── delete ────────────────────────────────────────────
memory
.command('delete <category> <id>')
.description(`Delete a memory entry (${CATEGORIES.join(', ')})`)
.option('--yes', 'Skip confirmation prompt')
.action(async (category: string, id: string, options: { yes?: boolean }) => {
if (!CATEGORIES.includes(category as Category)) {
log.error(`Invalid category: ${category}. Must be one of: ${CATEGORIES.join(', ')}`);
process.exit(1);
}
if (!options.yes) {
const confirmed = await confirm(`Delete this ${category} memory?`);
if (!confirmed) {
console.log('Cancelled.');
return;
}
}
const client = await getTrpcClient();
const router = client.userMemory as any;
const mutationName = `delete${capitalize(category)}`;
try {
await router[mutationName].mutate({ id });
console.log(`${pc.green('✓')} Deleted ${category} memory ${pc.bold(id)}`);
} catch (error: any) {
log.error(`Failed to delete ${category}: ${error.message}`);
process.exit(1);
return;
}
});
// ── persona ───────────────────────────────────────────
memory
.command('persona')
.description('View your memory persona summary')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(async (options: { json?: string | boolean }) => {
const client = await getTrpcClient();
const persona = await client.userMemory.getPersona.query();
if (options.json !== undefined) {
const fields = typeof options.json === 'string' ? options.json : undefined;
outputJson(persona, fields);
return;
}
if (!persona) {
console.log('No persona data available.');
return;
}
console.log(pc.bold('User Persona'));
console.log();
console.log(typeof persona === 'string' ? persona : JSON.stringify(persona, null, 2));
});
// ── extract ───────────────────────────────────────────
memory
.command('extract')
.description('Extract memories from chat history')
.option('--from <date>', 'Start date (ISO format)')
.option('--to <date>', 'End date (ISO format)')
.action(async (options: { from?: string; to?: string }) => {
const client = await getTrpcClient();
const input: { fromDate?: Date; toDate?: Date } = {};
if (options.from) input.fromDate = new Date(options.from);
if (options.to) input.toDate = new Date(options.to);
const result = await client.userMemory.requestMemoryFromChatTopic.mutate(input);
console.log(`${pc.green('✓')} Memory extraction started`);
if ((result as any)?.id) {
console.log(`Task ID: ${pc.bold((result as any).id)}`);
}
console.log(pc.dim('Use "lh memory extract-status" to check progress.'));
});
// ── extract-status ────────────────────────────────────
memory
.command('extract-status')
.description('Check memory extraction task status')
.option('--task-id <id>', 'Specific task ID to check')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(async (options: { json?: string | boolean; taskId?: string }) => {
const client = await getTrpcClient();
const input: { taskId?: string } = {};
if (options.taskId) input.taskId = options.taskId;
const result = await client.userMemory.getMemoryExtractionTask.query(input);
if (options.json !== undefined) {
const fields = typeof options.json === 'string' ? options.json : undefined;
outputJson(result, fields);
return;
}
if (!result) {
console.log('No extraction task found.');
return;
}
const r = result as any;
console.log(pc.bold('Memory Extraction Task'));
if (r.id) console.log(` ID: ${r.id}`);
if (r.status) console.log(` Status: ${r.status}`);
if (r.metadata) console.log(` Detail: ${JSON.stringify(r.metadata)}`);
});
}
// ── Helpers ─────────────────────────────────────────────────
async function fetchCategory(client: any, category: Category): Promise<any[]> {
const router = client.userMemory;
switch (category) {
case 'identity': {
return router.getIdentities.query();
}
case 'activity': {
return router.getActivities.query();
}
case 'context': {
return router.getContexts.query();
}
case 'experience': {
return router.getExperiences.query();
}
case 'preference': {
return router.getPreferences.query();
}
default: {
return [];
}
}
}
function buildCategoryInput(category: Category, options: Record<string, any>): Record<string, any> {
const input: Record<string, any> = {};
switch (category) {
case 'identity': {
if (options.type) input.type = options.type;
if (options.role) input.role = options.role;
if (options.relationship) input.relationship = options.relationship;
if (options.description) input.description = options.description;
if (options.labels) input.extractedLabels = options.labels;
break;
}
case 'activity': {
if (options.narrative) input.narrative = options.narrative;
if (options.notes) input.notes = options.notes;
if (options.status) input.status = options.status;
break;
}
case 'context': {
if (options.title) input.title = options.title;
if (options.description) input.description = options.description;
if (options.status) input.currentStatus = options.status;
break;
}
case 'experience': {
if (options.situation) input.situation = options.situation;
if (options.action) input.action = options.action;
if (options.keyLearning) input.keyLearning = options.keyLearning;
break;
}
case 'preference': {
if (options.directives) input.conclusionDirectives = options.directives;
if (options.suggestions) input.suggestions = options.suggestions;
break;
}
}
return input;
}
+138
View File
@@ -0,0 +1,138 @@
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { registerMessageCommand } from './message';
const { mockTrpcClient } = vi.hoisted(() => ({
mockTrpcClient: {
message: {
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() },
},
},
}));
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('message 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.message)) {
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();
registerMessageCommand(program);
return program;
}
describe('list', () => {
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 using getMessages', async () => {
mockTrpcClient.message.getMessages.query.mockResolvedValue([]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'message', 'list', '--topic-id', 't1']);
expect(mockTrpcClient.message.getMessages.query).toHaveBeenCalledWith(
expect.objectContaining({ topicId: 't1' }),
);
expect(mockTrpcClient.message.listAll.query).not.toHaveBeenCalled();
});
});
describe('search', () => {
it('should search messages', async () => {
mockTrpcClient.message.searchMessages.query.mockResolvedValue([]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'message', 'search', 'hello']);
expect(mockTrpcClient.message.searchMessages.query).toHaveBeenCalledWith({
keywords: 'hello',
});
});
});
describe('delete', () => {
it('should delete single message', async () => {
mockTrpcClient.message.removeMessage.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync(['node', 'test', 'message', 'delete', 'm1', '--yes']);
expect(mockTrpcClient.message.removeMessage.mutate).toHaveBeenCalledWith({ id: 'm1' });
});
it('should batch delete messages', async () => {
mockTrpcClient.message.removeMessages.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync(['node', 'test', 'message', 'delete', 'm1', 'm2', '--yes']);
expect(mockTrpcClient.message.removeMessages.mutate).toHaveBeenCalledWith({
ids: ['m1', 'm2'],
});
});
});
describe('count', () => {
it('should count messages', async () => {
mockTrpcClient.message.count.query.mockResolvedValue(42);
const program = createProgram();
await program.parseAsync(['node', 'test', 'message', 'count']);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('42'));
});
it('should output JSON', async () => {
mockTrpcClient.message.count.query.mockResolvedValue(42);
const program = createProgram();
await program.parseAsync(['node', 'test', 'message', 'count', '--json']);
expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify({ count: 42 }));
});
});
});
+382
View File
@@ -0,0 +1,382 @@
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 registerMessageCommand(program: Command) {
const message = program.command('message').description('Manage messages');
// ── list ──────────────────────────────────────────────
message
.command('list')
.description('List messages')
.option('--topic-id <id>', 'Filter by topic ID')
.option('--agent-id <id>', 'Filter by agent 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: {
agentId?: string;
json?: string | boolean;
limit?: string;
page?: string;
topicId?: string;
user?: boolean;
}) => {
const client = await getTrpcClient();
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;
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;
outputJson(items, fields);
return;
}
if (items.length === 0) {
console.log('No messages found.');
return;
}
const rows = items.map((m: any) => [
m.id || '',
m.role || '',
truncate(m.content || '', 60),
m.createdAt ? timeAgo(m.createdAt) : '',
]);
printTable(rows, ['ID', 'ROLE', 'CONTENT', 'CREATED']);
},
);
// ── search ────────────────────────────────────────────
message
.command('search <keywords>')
.description('Search messages')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(async (keywords: string, options: { json?: string | boolean }) => {
const client = await getTrpcClient();
const result = await client.message.searchMessages.query({ keywords });
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 messages found.');
return;
}
const rows = items.map((m: any) => [m.id || '', m.role || '', truncate(m.content || '', 60)]);
printTable(rows, ['ID', 'ROLE', 'CONTENT']);
});
// ── delete ────────────────────────────────────────────
message
.command('delete <ids...>')
.description('Delete one or more messages')
.option('--yes', 'Skip confirmation prompt')
.action(async (ids: string[], options: { yes?: boolean }) => {
if (!options.yes) {
const confirmed = await confirm(
`Are you sure you want to delete ${ids.length} message(s)?`,
);
if (!confirmed) {
console.log('Cancelled.');
return;
}
}
const client = await getTrpcClient();
if (ids.length === 1) {
await client.message.removeMessage.mutate({ id: ids[0] });
} else {
await client.message.removeMessages.mutate({ ids });
}
console.log(`${pc.green('✓')} Deleted ${ids.length} message(s)`);
});
// ── count ─────────────────────────────────────────────
message
.command('count')
.description('Count 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.count.query(input as any);
if (options.json) {
console.log(JSON.stringify({ count }));
return;
}
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
.command('heatmap')
.description('Get message activity heatmap')
.option('--json', 'Output JSON')
.action(async (options: { json?: boolean }) => {
const client = await getTrpcClient();
const result = await client.message.getHeatmaps.query();
if (options.json) {
console.log(JSON.stringify(result, null, 2));
return;
}
if (!result || (Array.isArray(result) && result.length === 0)) {
console.log('No heatmap data.');
return;
}
// Display as simple list
const items = Array.isArray(result) ? result : [result];
for (const entry of items) {
const e = entry as any;
console.log(`${e.date || e.day || ''}: ${pc.bold(String(e.count || e.value || 0))}`);
}
});
}
+316
View File
@@ -0,0 +1,316 @@
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { log } from '../utils/logger';
import { registerModelCommand } from './model';
const { mockTrpcClient } = vi.hoisted(() => ({
mockTrpcClient: {
aiModel: {
batchToggleAiModels: { 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() },
},
},
}));
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('model 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.aiModel)) {
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();
registerModelCommand(program);
return program;
}
describe('list', () => {
it('should list models for provider', async () => {
mockTrpcClient.aiModel.getAiProviderModelList.query.mockResolvedValue([
{ displayName: 'GPT-4', enabled: true, id: 'gpt-4', type: 'chat' },
]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'model', 'list', 'openai']);
expect(mockTrpcClient.aiModel.getAiProviderModelList.query).toHaveBeenCalledWith(
expect.objectContaining({ id: 'openai' }),
);
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', () => {
it('should display model details', async () => {
mockTrpcClient.aiModel.getAiModelById.query.mockResolvedValue({
displayName: 'GPT-4',
enabled: true,
id: 'gpt-4',
providerId: 'openai',
});
const program = createProgram();
await program.parseAsync(['node', 'test', 'model', 'view', 'gpt-4']);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('GPT-4'));
});
it('should exit when not found', async () => {
mockTrpcClient.aiModel.getAiModelById.query.mockResolvedValue(null);
const program = createProgram();
await program.parseAsync(['node', 'test', 'model', 'view', 'nonexistent']);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('not found'));
});
});
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({});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'model',
'toggle',
'gpt-4',
'--provider',
'openai',
'--enable',
]);
expect(mockTrpcClient.aiModel.toggleModelEnabled.mutate).toHaveBeenCalledWith(
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', () => {
it('should delete model', async () => {
mockTrpcClient.aiModel.removeAiModel.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'model',
'delete',
'gpt-4',
'--provider',
'openai',
'--yes',
]);
expect(mockTrpcClient.aiModel.removeAiModel.mutate).toHaveBeenCalledWith({
id: 'gpt-4',
providerId: 'openai',
});
});
});
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('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'));
});
});
});
+259
View File
@@ -0,0 +1,259 @@
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 registerModelCommand(program: Command) {
const model = program.command('model').description('Manage AI models');
// ── list ──────────────────────────────────────────────
model
.command('list <providerId>')
.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; 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);
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;
outputJson(items, fields);
return;
}
if (items.length === 0) {
console.log('No models found.');
return;
}
const rows = items.map((m: any) => [
m.id || '',
truncate(m.displayName || m.id || '', 40),
m.enabled ? pc.green('✓') : pc.dim('✗'),
m.type || '',
]);
printTable(rows, ['ID', 'NAME', 'ENABLED', 'TYPE']);
},
);
// ── view ──────────────────────────────────────────────
model
.command('view <id>')
.description('View model details')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(async (id: string, options: { json?: string | boolean }) => {
const client = await getTrpcClient();
const result = await client.aiModel.getAiModelById.query({ id });
if (!result) {
log.error(`Model not found: ${id}`);
process.exit(1);
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.displayName || r.id || 'Unknown'));
const meta: string[] = [];
if (r.providerId) meta.push(`Provider: ${r.providerId}`);
if (r.type) meta.push(`Type: ${r.type}`);
if (r.enabled !== undefined) meta.push(r.enabled ? 'Enabled' : 'Disabled');
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
.command('toggle <id>')
.description('Enable or disable a model')
.requiredOption('--provider <providerId>', 'Provider ID')
.option('--enable', 'Enable the model')
.option('--disable', 'Disable the model')
.action(
async (id: 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.toggleModelEnabled.mutate({
enabled,
id,
providerId: options.provider,
} as any);
console.log(`${pc.green('✓')} Model ${pc.bold(id)} ${enabled ? 'enabled' : 'disabled'}`);
},
);
// ── delete ────────────────────────────────────────────
model
.command('delete <id>')
.description('Delete a model')
.requiredOption('--provider <providerId>', 'Provider ID')
.option('--yes', 'Skip confirmation prompt')
.action(async (id: string, options: { provider: string; yes?: boolean }) => {
if (!options.yes) {
const confirmed = await confirm('Are you sure you want to delete this model?');
if (!confirmed) {
console.log('Cancelled.');
return;
}
}
const client = await getTrpcClient();
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)}`,
);
},
);
// ── 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)}`);
});
}
+159
View File
@@ -0,0 +1,159 @@
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { log } from '../utils/logger';
import { registerPluginCommand } from './plugin';
const { mockTrpcClient } = vi.hoisted(() => ({
mockTrpcClient: {
plugin: {
createOrInstallPlugin: { mutate: vi.fn() },
getPlugins: { query: vi.fn() },
removePlugin: { mutate: vi.fn() },
updatePlugin: { 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('plugin 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.plugin)) {
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();
registerPluginCommand(program);
return program;
}
describe('list', () => {
it('should list plugins', async () => {
mockTrpcClient.plugin.getPlugins.query.mockResolvedValue([
{ id: 'p1', identifier: 'search', type: 'plugin' },
]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'plugin', 'list']);
expect(consoleSpy).toHaveBeenCalledTimes(2);
});
it('should output JSON', async () => {
const plugins = [{ id: 'p1', identifier: 'search' }];
mockTrpcClient.plugin.getPlugins.query.mockResolvedValue(plugins);
const program = createProgram();
await program.parseAsync(['node', 'test', 'plugin', 'list', '--json']);
expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(plugins, null, 2));
});
});
describe('install', () => {
it('should install a plugin', async () => {
mockTrpcClient.plugin.createOrInstallPlugin.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'plugin',
'install',
'-i',
'my-plugin',
'--manifest',
'{"name":"test"}',
]);
expect(mockTrpcClient.plugin.createOrInstallPlugin.mutate).toHaveBeenCalledWith(
expect.objectContaining({
identifier: 'my-plugin',
manifest: { name: 'test' },
}),
);
});
it('should reject invalid manifest JSON', async () => {
const program = createProgram();
await program.parseAsync([
'node',
'test',
'plugin',
'install',
'-i',
'my-plugin',
'--manifest',
'not-json',
]);
expect(log.error).toHaveBeenCalledWith('Invalid manifest JSON.');
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
describe('uninstall', () => {
it('should uninstall with --yes', async () => {
mockTrpcClient.plugin.removePlugin.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync(['node', 'test', 'plugin', 'uninstall', 'p1', '--yes']);
expect(mockTrpcClient.plugin.removePlugin.mutate).toHaveBeenCalledWith({ id: 'p1' });
});
});
describe('update', () => {
it('should update plugin settings', async () => {
mockTrpcClient.plugin.updatePlugin.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'plugin',
'update',
'p1',
'--settings',
'{"key":"value"}',
]);
expect(mockTrpcClient.plugin.updatePlugin.mutate).toHaveBeenCalledWith(
expect.objectContaining({ id: 'p1', settings: { key: 'value' } }),
);
});
it('should exit when no changes', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'plugin', 'update', 'p1']);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('No changes'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
});
+146
View File
@@ -0,0 +1,146 @@
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 registerPluginCommand(program: Command) {
const plugin = program.command('plugin').description('Manage plugins');
// ── list ──────────────────────────────────────────────
plugin
.command('list')
.description('List installed plugins')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(async (options: { json?: string | boolean }) => {
const client = await getTrpcClient();
const result = await client.plugin.getPlugins.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 plugins installed.');
return;
}
const rows = items.map((p: any) => [
p.id || '',
truncate(p.identifier || '', 30),
p.type || '',
truncate(p.manifest?.meta?.title || p.manifest?.identifier || '', 30),
]);
printTable(rows, ['ID', 'IDENTIFIER', 'TYPE', 'TITLE']);
});
// ── install ───────────────────────────────────────────
plugin
.command('install')
.description('Install a plugin')
.requiredOption('-i, --identifier <id>', 'Plugin identifier')
.requiredOption('--manifest <json>', 'Plugin manifest JSON')
.option('--type <type>', 'Plugin type: plugin or customPlugin', 'plugin')
.option('--settings <json>', 'Plugin settings JSON')
.action(
async (options: {
identifier: string;
manifest: string;
settings?: string;
type: string;
}) => {
const client = await getTrpcClient();
let manifest: any;
let settings: any;
try {
manifest = JSON.parse(options.manifest);
} catch {
log.error('Invalid manifest JSON.');
process.exit(1);
}
if (options.settings) {
try {
settings = JSON.parse(options.settings);
} catch {
log.error('Invalid settings JSON.');
process.exit(1);
}
}
await client.plugin.createOrInstallPlugin.mutate({
customParams: {},
identifier: options.identifier,
manifest,
settings,
type: options.type as 'plugin' | 'customPlugin',
});
console.log(`${pc.green('✓')} Installed plugin ${pc.bold(options.identifier)}`);
},
);
// ── uninstall ─────────────────────────────────────────
plugin
.command('uninstall <id>')
.description('Uninstall a plugin')
.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 uninstall this plugin?');
if (!confirmed) {
console.log('Cancelled.');
return;
}
}
const client = await getTrpcClient();
await client.plugin.removePlugin.mutate({ id });
console.log(`${pc.green('✓')} Uninstalled plugin ${pc.bold(id)}`);
});
// ── update ────────────────────────────────────────────
plugin
.command('update <id>')
.description('Update plugin settings or manifest')
.option('--manifest <json>', 'New manifest JSON')
.option('--settings <json>', 'New settings JSON')
.action(async (id: string, options: { manifest?: string; settings?: string }) => {
const input: Record<string, any> = { id };
if (options.manifest) {
try {
input.manifest = JSON.parse(options.manifest);
} catch {
log.error('Invalid manifest JSON.');
process.exit(1);
}
}
if (options.settings) {
try {
input.settings = JSON.parse(options.settings);
} catch {
log.error('Invalid settings JSON.');
process.exit(1);
}
}
if (!options.manifest && !options.settings) {
log.error('No changes specified. Use --manifest or --settings.');
process.exit(1);
}
const client = await getTrpcClient();
await client.plugin.updatePlugin.mutate(input as any);
console.log(`${pc.green('✓')} Updated plugin ${pc.bold(id)}`);
});
}
+329
View File
@@ -0,0 +1,329 @@
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { log } from '../utils/logger';
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() },
},
},
}));
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('provider 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.aiProvider)) {
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();
registerProviderCommand(program);
return program;
}
describe('list', () => {
it('should list providers', async () => {
mockTrpcClient.aiProvider.getAiProviderList.query.mockResolvedValue([
{ enabled: true, id: 'openai', name: 'OpenAI' },
]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'provider', 'list']);
expect(consoleSpy).toHaveBeenCalledTimes(2);
});
it('should output JSON', async () => {
const providers = [{ id: 'openai', name: 'OpenAI' }];
mockTrpcClient.aiProvider.getAiProviderList.query.mockResolvedValue(providers);
const program = createProgram();
await program.parseAsync(['node', 'test', 'provider', 'list', '--json']);
expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(providers, null, 2));
});
});
describe('view', () => {
it('should display provider details', async () => {
mockTrpcClient.aiProvider.getAiProviderById.query.mockResolvedValue({
enabled: true,
id: 'openai',
name: 'OpenAI',
});
const program = createProgram();
await program.parseAsync(['node', 'test', 'provider', 'view', 'openai']);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('OpenAI'));
});
it('should exit when not found', async () => {
mockTrpcClient.aiProvider.getAiProviderById.query.mockResolvedValue(null);
const program = createProgram();
await program.parseAsync(['node', 'test', 'provider', 'view', 'nonexistent']);
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', () => {
it('should enable provider', async () => {
mockTrpcClient.aiProvider.toggleProviderEnabled.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync(['node', 'test', 'provider', 'toggle', 'openai', '--enable']);
expect(mockTrpcClient.aiProvider.toggleProviderEnabled.mutate).toHaveBeenCalledWith({
enabled: true,
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', () => {
it('should delete provider', async () => {
mockTrpcClient.aiProvider.removeAiProvider.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync(['node', 'test', 'provider', 'delete', 'openai', '--yes']);
expect(mockTrpcClient.aiProvider.removeAiProvider.mutate).toHaveBeenCalledWith({
id: 'openai',
});
});
});
});
+315
View File
@@ -0,0 +1,315 @@
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 registerProviderCommand(program: Command) {
const provider = program.command('provider').description('Manage AI providers');
// ── list ──────────────────────────────────────────────
provider
.command('list')
.description('List AI providers')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(async (options: { json?: string | boolean }) => {
const client = await getTrpcClient();
const result = await client.aiProvider.getAiProviderList.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 providers found.');
return;
}
const rows = items.map((p: any) => [
p.id || '',
truncate(p.name || p.id || '', 30),
p.enabled ? pc.green('✓') : pc.dim('✗'),
p.source || '',
]);
printTable(rows, ['ID', 'NAME', 'ENABLED', 'SOURCE']);
});
// ── view ──────────────────────────────────────────────
provider
.command('view <id>')
.description('View provider details')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(async (id: string, options: { json?: string | boolean }) => {
const client = await getTrpcClient();
const result = await client.aiProvider.getAiProviderById.query({ id });
if (!result || !(result as any).id) {
log.error(`Provider not found: ${id}`);
process.exit(1);
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.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
.command('toggle <id>')
.description('Enable or disable a provider')
.option('--enable', 'Enable the provider')
.option('--disable', 'Disable the provider')
.action(async (id: string, options: { disable?: boolean; enable?: boolean }) => {
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.aiProvider.toggleProviderEnabled.mutate({ enabled, id });
console.log(`${pc.green('✓')} Provider ${pc.bold(id)} ${enabled ? 'enabled' : 'disabled'}`);
});
// ── delete ────────────────────────────────────────────
provider
.command('delete <id>')
.description('Delete a provider')
.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 provider?');
if (!confirmed) {
console.log('Cancelled.');
return;
}
}
const client = await getTrpcClient();
await client.aiProvider.removeAiProvider.mutate({ id });
console.log(`${pc.green('✓')} Deleted provider ${pc.bold(id)}`);
});
}
+133
View File
@@ -0,0 +1,133 @@
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { registerSearchCommand } from './search';
const { mockTrpcClient } = vi.hoisted(() => ({
mockTrpcClient: {
search: {
query: { query: 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('search 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);
mockTrpcClient.search.query.query.mockReset();
});
afterEach(() => {
exitSpy.mockRestore();
consoleSpy.mockRestore();
});
function createProgram() {
const program = new Command();
program.exitOverride();
registerSearchCommand(program);
return program;
}
it('should search with query string', async () => {
mockTrpcClient.search.query.query.mockResolvedValue([]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'search', 'hello']);
expect(mockTrpcClient.search.query.query).toHaveBeenCalledWith(
expect.objectContaining({ query: 'hello' }),
);
});
it('should filter by type', async () => {
mockTrpcClient.search.query.query.mockResolvedValue([]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'search', 'test', '--type', 'agent']);
expect(mockTrpcClient.search.query.query).toHaveBeenCalledWith(
expect.objectContaining({ query: 'test', type: 'agent' }),
);
});
it('should respect --limit flag', async () => {
mockTrpcClient.search.query.query.mockResolvedValue([]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'search', 'test', '-L', '5']);
expect(mockTrpcClient.search.query.query).toHaveBeenCalledWith(
expect.objectContaining({ limitPerType: 5 }),
);
});
it('should output JSON when --json flag is used', async () => {
const results = [{ id: '1', title: 'Test', type: 'agent' }];
mockTrpcClient.search.query.query.mockResolvedValue(results);
const program = createProgram();
await program.parseAsync(['node', 'test', 'search', 'test', '--json']);
expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(results, null, 2));
});
it('should show message when no results found', async () => {
mockTrpcClient.search.query.query.mockResolvedValue([]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'search', 'nothing']);
expect(consoleSpy).toHaveBeenCalledWith('No results found.');
});
it('should display grouped results for array response', async () => {
mockTrpcClient.search.query.query.mockResolvedValue([
{ id: '1', title: 'Agent 1', type: 'agent' },
{ id: '2', title: 'Topic 1', type: 'topic' },
]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'search', 'test']);
// Should display group headers
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('agent'));
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('topic'));
});
it('should display grouped results for object response', async () => {
mockTrpcClient.search.query.query.mockResolvedValue({
agents: [{ id: '1', title: 'Agent 1' }],
topics: [{ id: '2', title: 'Topic 1' }],
});
const program = createProgram();
await program.parseAsync(['node', 'test', 'search', 'test']);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('agents'));
});
it('should reject invalid type', async () => {
const program = createProgram();
const stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await program.parseAsync(['node', 'test', 'search', 'test', '--type', 'invalid']);
expect(exitSpy).toHaveBeenCalledWith(1);
stderrSpy.mockRestore();
});
});
+318
View File
@@ -0,0 +1,318 @@
import type { Command } from 'commander';
import pc from 'picocolors';
import { getToolsTrpcClient, getTrpcClient } from '../api/client';
import { outputJson, printTable, truncate } from '../utils/format';
const SEARCH_TYPES = [
'agent',
'topic',
'file',
'folder',
'message',
'page',
'memory',
'mcp',
'plugin',
'communityAgent',
'knowledgeBase',
] as const;
type SearchType = (typeof SEARCH_TYPES)[number];
function renderResultGroup(type: string, items: any[]) {
if (items.length === 0) return;
console.log();
console.log(pc.bold(pc.cyan(`── ${type} (${items.length}) ──`)));
const rows = items.map((item: any) => [
item.id || '',
truncate(item.title || item.name || item.content || 'Untitled', 80),
item.description ? truncate(item.description, 40) : '',
]);
printTable(rows, ['ID', 'TITLE', 'DESCRIPTION']);
}
export function registerSearchCommand(program: Command) {
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 (options: {
categories?: string;
engines?: string;
json?: string | boolean;
limit?: string;
query?: string;
timeRange?: string;
type?: string;
web?: boolean;
}) => {
if (!options.query) {
search.help();
return;
}
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)`);
});
}
+403
View File
@@ -0,0 +1,403 @@
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { log } from '../utils/logger';
import { detectSourceType, registerSkillCommand } from './skill';
const { mockTrpcClient } = vi.hoisted(() => ({
mockTrpcClient: {
agentSkills: {
create: { mutate: vi.fn() },
delete: { mutate: vi.fn() },
getById: { query: vi.fn() },
importFromGitHub: { mutate: vi.fn() },
importFromMarket: { mutate: vi.fn() },
importFromUrl: { mutate: vi.fn() },
list: { query: vi.fn() },
listResources: { query: vi.fn() },
readResource: { query: vi.fn() },
search: { 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('skill command', () => {
let exitSpy: ReturnType<typeof vi.spyOn>;
let consoleSpy: ReturnType<typeof vi.spyOn>;
let stdoutSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
mockGetTrpcClient.mockResolvedValue(mockTrpcClient);
for (const method of Object.values(mockTrpcClient.agentSkills)) {
for (const fn of Object.values(method)) {
(fn as ReturnType<typeof vi.fn>).mockReset();
}
}
});
afterEach(() => {
exitSpy.mockRestore();
consoleSpy.mockRestore();
stdoutSpy.mockRestore();
});
function createProgram() {
const program = new Command();
program.exitOverride();
registerSkillCommand(program);
return program;
}
describe('list', () => {
it('should display skills in table format', async () => {
mockTrpcClient.agentSkills.list.query.mockResolvedValue([
{
description: 'A skill',
id: 's1',
identifier: 'test-skill',
name: 'Test Skill',
source: 'user',
},
]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'skill', 'list']);
expect(consoleSpy).toHaveBeenCalledTimes(2); // header + 1 row
expect(consoleSpy.mock.calls[0][0]).toContain('ID');
});
it('should output JSON when --json flag is used', async () => {
const items = [{ id: 's1', name: 'Test' }];
mockTrpcClient.agentSkills.list.query.mockResolvedValue(items);
const program = createProgram();
await program.parseAsync(['node', 'test', 'skill', 'list', '--json']);
expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(items, null, 2));
});
it('should filter by source', async () => {
mockTrpcClient.agentSkills.list.query.mockResolvedValue([]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'skill', 'list', '--source', 'builtin']);
expect(mockTrpcClient.agentSkills.list.query).toHaveBeenCalledWith(
expect.objectContaining({ source: 'builtin' }),
);
});
it('should reject invalid source', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'skill', 'list', '--source', 'invalid']);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Invalid source'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should show message when no skills found', async () => {
mockTrpcClient.agentSkills.list.query.mockResolvedValue([]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'skill', 'list']);
expect(consoleSpy).toHaveBeenCalledWith('No skills found.');
});
});
describe('view', () => {
it('should display skill details', async () => {
mockTrpcClient.agentSkills.getById.query.mockResolvedValue({
content: 'Skill content here',
description: 'A test skill',
id: 's1',
name: 'Test Skill',
source: 'user',
});
const program = createProgram();
await program.parseAsync(['node', 'test', 'skill', 'view', 's1']);
expect(mockTrpcClient.agentSkills.getById.query).toHaveBeenCalledWith({ id: 's1' });
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Test Skill'));
});
it('should exit when not found', async () => {
mockTrpcClient.agentSkills.getById.query.mockResolvedValue(null);
const program = createProgram();
await program.parseAsync(['node', 'test', 'skill', 'view', 'nonexistent']);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('not found'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
describe('create', () => {
it('should create a skill', async () => {
mockTrpcClient.agentSkills.create.mutate.mockResolvedValue({ id: 'new-skill' });
const program = createProgram();
await program.parseAsync([
'node',
'test',
'skill',
'create',
'--name',
'My Skill',
'--description',
'A skill',
'--content',
'Do something',
]);
expect(mockTrpcClient.agentSkills.create.mutate).toHaveBeenCalledWith(
expect.objectContaining({
content: 'Do something',
description: 'A skill',
name: 'My Skill',
}),
);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('new-skill'));
});
});
describe('edit', () => {
it('should update skill content', async () => {
mockTrpcClient.agentSkills.update.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync(['node', 'test', 'skill', 'edit', 's1', '--content', 'updated']);
expect(mockTrpcClient.agentSkills.update.mutate).toHaveBeenCalledWith(
expect.objectContaining({ content: 'updated', id: 's1' }),
);
});
it('should exit when no changes specified', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'skill', 'edit', 's1']);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('No changes'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
describe('delete', () => {
it('should delete with --yes', async () => {
mockTrpcClient.agentSkills.delete.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync(['node', 'test', 'skill', 'delete', 's1', '--yes']);
expect(mockTrpcClient.agentSkills.delete.mutate).toHaveBeenCalledWith({ id: 's1' });
});
});
describe('search', () => {
it('should search skills', async () => {
mockTrpcClient.agentSkills.search.query.mockResolvedValue([
{ description: 'A skill', id: 's1', name: 'Found Skill' },
]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'skill', 'search', 'test']);
expect(mockTrpcClient.agentSkills.search.query).toHaveBeenCalledWith({ query: 'test' });
expect(consoleSpy).toHaveBeenCalledTimes(2); // header + 1 row
});
it('should show message when no results', async () => {
mockTrpcClient.agentSkills.search.query.mockResolvedValue([]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'skill', 'search', 'nothing']);
expect(consoleSpy).toHaveBeenCalledWith('No skills found.');
});
});
describe('install', () => {
it('should install from GitHub URL', async () => {
mockTrpcClient.agentSkills.importFromGitHub.mutate.mockResolvedValue({
id: 'imported',
name: 'GH Skill',
});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'skill',
'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('Installed'));
});
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',
'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', () => {
it('should list resources', async () => {
mockTrpcClient.agentSkills.listResources.query.mockResolvedValue([
{ name: 'file.txt', size: 1024, type: 'text' },
]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'skill', 'resources', 's1']);
expect(mockTrpcClient.agentSkills.listResources.query).toHaveBeenCalledWith({ id: 's1' });
expect(consoleSpy).toHaveBeenCalledTimes(2); // header + 1 row
});
it('should show message when no resources', async () => {
mockTrpcClient.agentSkills.listResources.query.mockResolvedValue([]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'skill', 'resources', 's1']);
expect(consoleSpy).toHaveBeenCalledWith('No resources found.');
});
});
describe('read-resource', () => {
it('should output resource content', async () => {
mockTrpcClient.agentSkills.readResource.query.mockResolvedValue({
content: 'file contents here',
});
const program = createProgram();
await program.parseAsync(['node', 'test', 'skill', 'read-resource', 's1', 'file.txt']);
expect(mockTrpcClient.agentSkills.readResource.query).toHaveBeenCalledWith({
id: 's1',
path: 'file.txt',
});
expect(stdoutSpy).toHaveBeenCalledWith('file contents here');
});
it('should exit when resource not found', async () => {
mockTrpcClient.agentSkills.readResource.query.mockResolvedValue(null);
const program = createProgram();
await program.parseAsync(['node', 'test', 'skill', 'read-resource', 's1', 'missing.txt']);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('not found'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
});
+320
View File
@@ -0,0 +1,320 @@
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';
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');
// ── list ──────────────────────────────────────────────
skill
.command('list')
.description('List skills')
.option('--source <source>', 'Filter by source: builtin, market, user')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(async (options: { json?: string | boolean; source?: string }) => {
if (options.source && !['builtin', 'market', 'user'].includes(options.source)) {
log.error('Invalid source. Must be one of: builtin, market, user');
process.exit(1);
return;
}
const client = await getTrpcClient();
const input: { source?: 'builtin' | 'market' | 'user' } = {};
if (options.source) input.source = options.source as 'builtin' | 'market' | 'user';
const result = await client.agentSkills.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 skills found.');
return;
}
const rows = items.map((s: any) => [
s.id || '',
truncate(s.name || '', 30),
truncate(s.description || '', 40),
s.source || '',
s.identifier || '',
]);
printTable(rows, ['ID', 'NAME', 'DESCRIPTION', 'SOURCE', 'IDENTIFIER']);
});
// ── view ──────────────────────────────────────────────
skill
.command('view <id>')
.description('View skill details')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(async (id: string, options: { json?: string | boolean }) => {
const client = await getTrpcClient();
const result = await client.agentSkills.getById.query({ id });
if (!result) {
log.error(`Skill not found: ${id}`);
process.exit(1);
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.name || 'Untitled'));
const meta: string[] = [];
if (r.description) meta.push(r.description);
if (r.source) meta.push(`Source: ${r.source}`);
if (r.identifier) meta.push(`ID: ${r.identifier}`);
if (meta.length > 0) console.log(pc.dim(meta.join(' · ')));
if (r.content) {
console.log();
console.log(pc.bold('Content:'));
console.log(r.content);
}
});
// ── create ────────────────────────────────────────────
skill
.command('create')
.description('Create a user skill')
.requiredOption('-n, --name <name>', 'Skill name')
.requiredOption('-d, --description <desc>', 'Skill description')
.requiredOption('-c, --content <content>', 'Skill content (prompt)')
.option('-i, --identifier <id>', 'Custom identifier')
.action(
async (options: {
content: string;
description: string;
identifier?: string;
name: string;
}) => {
const client = await getTrpcClient();
const input: {
content: string;
description: string;
identifier?: string;
name: string;
} = {
content: options.content,
description: options.description,
name: options.name,
};
if (options.identifier) input.identifier = options.identifier;
const result = await client.agentSkills.create.mutate(input);
const r = result as any;
console.log(`${pc.green('✓')} Created skill ${pc.bold(r.id || r)}`);
},
);
// ── edit ──────────────────────────────────────────────
skill
.command('edit <id>')
.description('Update a skill')
.option('-c, --content <content>', 'New content')
.option('-n, --name <name>', 'New name (via manifest)')
.option('-d, --description <desc>', 'New description (via manifest)')
.action(
async (id: string, options: { content?: string; description?: string; name?: string }) => {
if (!options.content && !options.name && !options.description) {
log.error('No changes specified. Use --content, --name, or --description.');
process.exit(1);
return;
}
const client = await getTrpcClient();
const input: Record<string, any> = { id };
if (options.content) input.content = options.content;
if (options.name || options.description) {
const manifest: Record<string, any> = {};
if (options.name) manifest.name = options.name;
if (options.description) manifest.description = options.description;
input.manifest = manifest;
}
await client.agentSkills.update.mutate(input as any);
console.log(`${pc.green('✓')} Updated skill ${pc.bold(id)}`);
},
);
// ── delete ────────────────────────────────────────────
skill
.command('delete <id>')
.description('Delete a skill')
.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 skill?');
if (!confirmed) {
console.log('Cancelled.');
return;
}
}
const client = await getTrpcClient();
await client.agentSkills.delete.mutate({ id });
console.log(`${pc.green('✓')} Deleted skill ${pc.bold(id)}`);
});
// ── search ────────────────────────────────────────────
skill
.command('search <query>')
.description('Search skills')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(async (query: string, options: { json?: string | boolean }) => {
const client = await getTrpcClient();
const result = await client.agentSkills.search.query({ 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 skills found.');
return;
}
const rows = items.map((s: any) => [
s.id || '',
truncate(s.name || '', 30),
truncate(s.description || '', 50),
]);
printTable(rows, ['ID', 'NAME', 'DESCRIPTION']);
});
// ── install (alias: i) ───────────────────────────────────
skill
.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);
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('✓')} 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 ─────────────────────────────────────────
skill
.command('resources <id>')
.description('List skill resource files')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(async (id: string, options: { json?: string | boolean }) => {
const client = await getTrpcClient();
const result = await client.agentSkills.listResources.query({ id });
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 resources found.');
return;
}
const rows = items.map((r: any) => [
truncate(r.path || r.name || '', 60),
r.type || '',
r.size ? `${Math.round(r.size / 1024)}KB` : '',
]);
printTable(rows, ['PATH', 'TYPE', 'SIZE']);
});
// ── read-resource ─────────────────────────────────────
skill
.command('read-resource <id> <path>')
.description('Read a skill resource file')
.action(async (id: string, path: string) => {
const client = await getTrpcClient();
const result = await client.agentSkills.readResource.query({ id, path });
if (!result) {
log.error(`Resource not found: ${path}`);
process.exit(1);
return;
}
const r = result as any;
if (r.content) {
process.stdout.write(r.content);
} else {
console.log(JSON.stringify(result, null, 2));
}
});
}
+204
View File
@@ -0,0 +1,204 @@
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Mock resolveToken
vi.mock('../auth/resolveToken', () => ({
resolveToken: vi.fn().mockResolvedValue({ token: 'test-token', userId: 'test-user' }),
}));
vi.mock('../settings', () => ({
loadSettings: vi.fn().mockReturnValue(null),
saveSettings: vi.fn(),
}));
vi.mock('../utils/logger', () => ({
log: {
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
},
setVerbose: vi.fn(),
}));
// Track event handlers registered on GatewayClient instances
let clientEventHandlers: Record<string, (...args: any[]) => any> = {};
let connectCalled = false;
let clientOptions: any = {};
vi.mock('@lobechat/device-gateway-client', () => ({
GatewayClient: vi.fn().mockImplementation((opts: any) => {
clientOptions = opts;
clientEventHandlers = {};
connectCalled = false;
return {
connect: vi.fn().mockImplementation(async () => {
connectCalled = true;
}),
disconnect: vi.fn(),
on: vi.fn().mockImplementation((event: string, handler: (...args: any[]) => any) => {
clientEventHandlers[event] = handler;
}),
};
}),
}));
// eslint-disable-next-line import-x/first
import { loadSettings, saveSettings } from '../settings';
// eslint-disable-next-line import-x/first
import { log } from '../utils/logger';
// eslint-disable-next-line import-x/first
import { registerStatusCommand } from './status';
describe('status command', () => {
let exitSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.useFakeTimers();
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
});
afterEach(() => {
vi.useRealTimers();
exitSpy.mockRestore();
vi.clearAllMocks();
});
function createProgram() {
const program = new Command();
program.exitOverride();
registerStatusCommand(program);
return program;
}
it('should create client with autoReconnect false', async () => {
const program = createProgram();
const parsePromise = program.parseAsync(['node', 'test', 'status']);
await vi.advanceTimersByTimeAsync(0);
// Trigger connected to finish the command
clientEventHandlers['connected']?.();
await parsePromise;
expect(clientOptions.autoReconnect).toBe(false);
});
it('should require explicit gateway for custom login server', async () => {
vi.mocked(loadSettings).mockReturnValueOnce({ serverUrl: 'https://self-hosted.example.com' });
const program = createProgram();
await expect(program.parseAsync(['node', 'test', 'status'])).rejects.toThrow('process.exit');
expect(log.error).toHaveBeenCalledWith(
"Current login uses custom --server https://self-hosted.example.com. Please also provide '--gateway <url>' for the device gateway.",
);
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should use explicit gateway for custom login server', async () => {
vi.mocked(loadSettings).mockReturnValueOnce({ serverUrl: 'https://self-hosted.example.com' });
const program = createProgram();
const parsePromise = program.parseAsync([
'node',
'test',
'status',
'--gateway',
'https://gateway.example.com/',
]);
await vi.advanceTimersByTimeAsync(0);
clientEventHandlers['connected']?.();
await parsePromise;
expect(clientOptions.gatewayUrl).toBe('https://gateway.example.com');
expect(saveSettings).toHaveBeenCalledWith({
gatewayUrl: 'https://gateway.example.com',
serverUrl: 'https://self-hosted.example.com',
});
});
it('should log CONNECTED on successful connection', async () => {
const program = createProgram();
const parsePromise = program.parseAsync(['node', 'test', 'status']);
await vi.advanceTimersByTimeAsync(0);
clientEventHandlers['connected']?.();
await parsePromise;
expect(log.info).toHaveBeenCalledWith('CONNECTED');
expect(exitSpy).toHaveBeenCalledWith(0);
});
it('should log FAILED on disconnected', async () => {
const program = createProgram();
const parsePromise = program.parseAsync(['node', 'test', 'status']);
await vi.advanceTimersByTimeAsync(0);
clientEventHandlers['disconnected']?.();
await parsePromise;
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('FAILED'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should log FAILED on auth_failed', async () => {
const program = createProgram();
const parsePromise = program.parseAsync(['node', 'test', 'status']);
await vi.advanceTimersByTimeAsync(0);
clientEventHandlers['auth_failed']?.('bad token');
await parsePromise;
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Authentication failed'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should log FAILED on auth_expired', async () => {
const program = createProgram();
const parsePromise = program.parseAsync(['node', 'test', 'status']);
await vi.advanceTimersByTimeAsync(0);
clientEventHandlers['auth_expired']?.();
await parsePromise;
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('expired'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should log connection error', async () => {
const program = createProgram();
const parsePromise = program.parseAsync(['node', 'test', 'status']);
await vi.advanceTimersByTimeAsync(0);
clientEventHandlers['error']?.(new Error('network issue'));
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('network issue'));
// Clean up by triggering connected
clientEventHandlers['connected']?.();
await parsePromise;
});
it('should timeout if no connection within timeout period', async () => {
const program = createProgram();
const parsePromise = program.parseAsync(['node', 'test', 'status', '--timeout', '5000']);
// Advance timer past timeout
await vi.advanceTimersByTimeAsync(5001);
await parsePromise;
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('timed out'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should call connect on the client', async () => {
const program = createProgram();
const parsePromise = program.parseAsync(['node', 'test', 'status']);
await vi.advanceTimersByTimeAsync(0);
expect(connectCalled).toBe(true);
// Clean up
clientEventHandlers['connected']?.();
await parsePromise;
});
});
+95
View File
@@ -0,0 +1,95 @@
import { GatewayClient } from '@lobechat/device-gateway-client';
import type { Command } from 'commander';
import { resolveToken } from '../auth/resolveToken';
import { OFFICIAL_GATEWAY_URL } from '../constants/urls';
import { loadSettings, saveSettings } from '../settings';
import { log, setVerbose } from '../utils/logger';
interface StatusOptions {
gateway?: string;
serviceToken?: string;
timeout?: string;
token?: string;
userId?: string;
verbose?: boolean;
}
export function registerStatusCommand(program: Command) {
program
.command('status')
.description('Check if gateway connection can be established')
.option('--token <jwt>', 'JWT access token')
.option('--service-token <token>', 'Service token (requires --user-id)')
.option('--user-id <id>', 'User ID (required with --service-token)')
.option('--gateway <url>', 'Device gateway URL')
.option('--timeout <ms>', 'Connection timeout in ms', '10000')
.option('-v, --verbose', 'Enable verbose logging')
.action(async (options: StatusOptions) => {
if (options.verbose) setVerbose(true);
const auth = await resolveToken(options);
const settings = loadSettings();
const gatewayUrl = options.gateway?.replace(/\/$/, '') || settings?.gatewayUrl;
if (!gatewayUrl && settings?.serverUrl) {
log.error(
`Current login uses custom --server ${settings?.serverUrl}. Please also provide '--gateway <url>' for the device gateway.`,
);
process.exit(1);
throw new Error('process.exit');
}
if (options.gateway && gatewayUrl) {
saveSettings({ ...settings, gatewayUrl });
}
const timeout = Number.parseInt(options.timeout || '10000', 10);
const client = new GatewayClient({
autoReconnect: false,
gatewayUrl: gatewayUrl || OFFICIAL_GATEWAY_URL,
logger: log,
token: auth.token,
userId: auth.userId,
});
const timer = setTimeout(() => {
log.error('FAILED - Connection timed out');
client.disconnect();
process.exit(1);
}, timeout);
client.on('connected', () => {
clearTimeout(timer);
log.info('CONNECTED');
client.disconnect();
process.exit(0);
});
client.on('disconnected', () => {
clearTimeout(timer);
log.error('FAILED - Connection closed by server');
process.exit(1);
});
client.on('auth_failed', (reason) => {
clearTimeout(timer);
log.error(`FAILED - Authentication failed: ${reason}`);
process.exit(1);
});
client.on('auth_expired', () => {
clearTimeout(timer);
log.error('FAILED - Authentication expired');
client.disconnect();
process.exit(1);
});
client.on('error', (error) => {
log.error(`Connection error: ${error.message}`);
});
await client.connect();
});
}
+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)}`);
});
}
+164
View File
@@ -0,0 +1,164 @@
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { log } from '../utils/logger';
import { registerTopicCommand } from './topic';
const { mockTrpcClient } = vi.hoisted(() => ({
mockTrpcClient: {
topic: {
batchDelete: { mutate: vi.fn() },
createTopic: { mutate: vi.fn() },
getTopics: { query: vi.fn() },
recentTopics: { query: vi.fn() },
removeTopic: { mutate: vi.fn() },
searchTopics: { query: vi.fn() },
updateTopic: { 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('topic 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.topic)) {
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();
registerTopicCommand(program);
return program;
}
describe('list', () => {
it('should display topics', async () => {
mockTrpcClient.topic.getTopics.query.mockResolvedValue([
{ id: 't1', title: 'Topic 1', updatedAt: new Date().toISOString() },
]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'topic', 'list']);
expect(consoleSpy).toHaveBeenCalledTimes(2);
});
it('should filter by agent-id', async () => {
mockTrpcClient.topic.getTopics.query.mockResolvedValue([]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'topic', 'list', '--agent-id', 'a1']);
expect(mockTrpcClient.topic.getTopics.query).toHaveBeenCalledWith(
expect.objectContaining({ agentId: 'a1' }),
);
});
});
describe('search', () => {
it('should search topics', async () => {
mockTrpcClient.topic.searchTopics.query.mockResolvedValue([]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'topic', 'search', 'hello']);
expect(mockTrpcClient.topic.searchTopics.query).toHaveBeenCalledWith(
expect.objectContaining({ keywords: 'hello' }),
);
});
});
describe('create', () => {
it('should create a topic', async () => {
mockTrpcClient.topic.createTopic.mutate.mockResolvedValue({ id: 't-new' });
const program = createProgram();
await program.parseAsync(['node', 'test', 'topic', 'create', '-t', 'New Topic']);
expect(mockTrpcClient.topic.createTopic.mutate).toHaveBeenCalledWith(
expect.objectContaining({ title: 'New Topic' }),
);
});
});
describe('edit', () => {
it('should update a topic', async () => {
mockTrpcClient.topic.updateTopic.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync(['node', 'test', 'topic', 'edit', 't1', '-t', 'Updated']);
expect(mockTrpcClient.topic.updateTopic.mutate).toHaveBeenCalledWith({
id: 't1',
value: { title: 'Updated' },
});
});
it('should exit when no changes', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'topic', 'edit', 't1']);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('No changes'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
describe('delete', () => {
it('should delete single topic', async () => {
mockTrpcClient.topic.removeTopic.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync(['node', 'test', 'topic', 'delete', 't1', '--yes']);
expect(mockTrpcClient.topic.removeTopic.mutate).toHaveBeenCalledWith({ id: 't1' });
});
it('should batch delete multiple topics', async () => {
mockTrpcClient.topic.batchDelete.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync(['node', 'test', 'topic', 'delete', 't1', 't2', '--yes']);
expect(mockTrpcClient.topic.batchDelete.mutate).toHaveBeenCalledWith({
ids: ['t1', 't2'],
});
});
});
describe('recent', () => {
it('should list recent topics', async () => {
mockTrpcClient.topic.recentTopics.query.mockResolvedValue([
{ id: 't1', title: 'Recent', updatedAt: new Date().toISOString() },
]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'topic', 'recent']);
expect(mockTrpcClient.topic.recentTopics.query).toHaveBeenCalledWith({ limit: 10 });
});
});
});
+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, timeAgo, truncate } from '../utils/format';
import { log } from '../utils/logger';
export function registerTopicCommand(program: Command) {
const topic = program.command('topic').description('Manage conversation topics');
// ── list ──────────────────────────────────────────────
topic
.command('list')
.description('List topics')
.option('--agent-id <id>', 'Filter by agent ID')
.option('-L, --limit <n>', 'Page size', '30')
.option('--page <n>', 'Page number', '1')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(
async (options: {
agentId?: string;
json?: string | boolean;
limit?: string;
page?: string;
}) => {
const client = await getTrpcClient();
const input: Record<string, any> = {};
if (options.agentId) input.agentId = options.agentId;
if (options.limit) input.pageSize = Number.parseInt(options.limit, 10);
if (options.page) input.current = Number.parseInt(options.page, 10);
const result = await client.topic.getTopics.query(input as any);
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 topics found.');
return;
}
const rows = items.map((t: any) => [
t.id || '',
truncate(t.title || 'Untitled', 50),
t.favorite ? '★' : '',
t.updatedAt ? timeAgo(t.updatedAt) : '',
]);
printTable(rows, ['ID', 'TITLE', 'FAV', 'UPDATED']);
},
);
// ── search ────────────────────────────────────────────
topic
.command('search <keywords>')
.description('Search topics')
.option('--agent-id <id>', 'Filter by agent ID')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(async (keywords: string, options: { agentId?: string; json?: string | boolean }) => {
const client = await getTrpcClient();
const input: Record<string, any> = { keywords };
if (options.agentId) input.agentId = options.agentId;
const result = await client.topic.searchTopics.query(input as any);
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 topics found.');
return;
}
const rows = items.map((t: any) => [t.id || '', truncate(t.title || 'Untitled', 50)]);
printTable(rows, ['ID', 'TITLE']);
});
// ── create ────────────────────────────────────────────
topic
.command('create')
.description('Create a topic')
.requiredOption('-t, --title <title>', 'Topic title')
.option('--agent-id <id>', 'Agent ID')
.option('--favorite', 'Mark as favorite')
.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.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)}`);
});
// ── edit ──────────────────────────────────────────────
topic
.command('edit <id>')
.description('Update a topic')
.option('-t, --title <title>', 'New title')
.option('--favorite', 'Mark as favorite')
.option('--no-favorite', 'Unmark as favorite')
.action(async (id: string, options: { favorite?: boolean; title?: string }) => {
const value: Record<string, any> = {};
if (options.title) value.title = options.title;
if (options.favorite !== undefined) value.favorite = options.favorite;
if (Object.keys(value).length === 0) {
log.error('No changes specified. Use --title or --favorite.');
process.exit(1);
}
const client = await getTrpcClient();
await client.topic.updateTopic.mutate({ id, value });
console.log(`${pc.green('✓')} Updated topic ${pc.bold(id)}`);
});
// ── delete ────────────────────────────────────────────
topic
.command('delete <ids...>')
.description('Delete one or more topics')
.option('--yes', 'Skip confirmation prompt')
.action(async (ids: string[], options: { yes?: boolean }) => {
if (!options.yes) {
const confirmed = await confirm(`Are you sure you want to delete ${ids.length} topic(s)?`);
if (!confirmed) {
console.log('Cancelled.');
return;
}
}
const client = await getTrpcClient();
if (ids.length === 1) {
await client.topic.removeTopic.mutate({ id: ids[0] });
} else {
await client.topic.batchDelete.mutate({ ids });
}
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
.command('recent')
.description('List recent topics')
.option('-L, --limit <n>', 'Number of items', '10')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(async (options: { json?: string | boolean; limit?: string }) => {
const client = await getTrpcClient();
const limit = Number.parseInt(options.limit || '10', 10);
const result = await client.topic.recentTopics.query({ limit });
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 recent topics.');
return;
}
const rows = items.map((t: any) => [
t.id || '',
truncate(t.title || 'Untitled', 50),
t.updatedAt ? timeAgo(t.updatedAt) : '',
]);
printTable(rows, ['ID', 'TITLE', 'UPDATED']);
});
}

Some files were not shown because too many files have changed in this diff Show More