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>
This commit is contained in:
Arvin Xu
2026-03-11 14:29:15 +08:00
committed by GitHub
parent 874c2dd706
commit 8e60b9f620
10 changed files with 1091 additions and 63 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@lobehub/cli",
"version": "0.0.1-canary.8",
"version": "0.0.1-canary.10",
"type": "module",
"bin": {
"lh": "./dist/index.js",

View File

@@ -2,6 +2,7 @@ import { createTRPCClient, httpLink } from '@trpc/client';
import superjson from 'superjson';
import type { LambdaRouter } from '@/server/routers/lambda';
import type { ToolsRouter } from '@/server/routers/tools';
import { getValidToken } from '../auth/refresh';
import { OFFICIAL_SERVER_URL } from '../constants/urls';
@@ -9,12 +10,12 @@ import { loadSettings } from '../settings';
import { log } from '../utils/logger';
export type TrpcClient = ReturnType<typeof createTRPCClient<LambdaRouter>>;
export type ToolsTrpcClient = ReturnType<typeof createTRPCClient<ToolsRouter>>;
let _client: TrpcClient | undefined;
let _toolsClient: ToolsTrpcClient | undefined;
export async function getTrpcClient(): Promise<TrpcClient> {
if (_client) return _client;
async function getAuthAndServer() {
const result = await getValidToken();
if (!result) {
log.error("No authentication found. Run 'lh login' first.");
@@ -24,17 +25,41 @@ export async function getTrpcClient(): Promise<TrpcClient> {
const accessToken = result.credentials.accessToken;
const serverUrl = loadSettings()?.serverUrl || OFFICIAL_SERVER_URL;
return { accessToken, serverUrl: serverUrl.replace(/\/$/, '') };
}
export async function getTrpcClient(): Promise<TrpcClient> {
if (_client) return _client;
const { accessToken, serverUrl } = await getAuthAndServer();
_client = createTRPCClient<LambdaRouter>({
links: [
httpLink({
headers: {
'Oidc-Auth': accessToken,
},
headers: { 'Oidc-Auth': accessToken },
transformer: superjson,
url: `${serverUrl.replace(/\/$/, '')}/trpc/lambda`,
url: `${serverUrl}/trpc/lambda`,
}),
],
});
return _client;
}
export async function getToolsTrpcClient(): Promise<ToolsTrpcClient> {
if (_toolsClient) return _toolsClient;
const { accessToken, serverUrl } = await getAuthAndServer();
_toolsClient = createTRPCClient<ToolsRouter>({
links: [
httpLink({
headers: { 'Oidc-Auth': accessToken },
transformer: superjson,
url: `${serverUrl}/trpc/tools`,
}),
],
});
return _toolsClient;
}

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);
});
});
});

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}`);
}
});
}

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}`);
});
}

View File

@@ -1,7 +1,7 @@
import type { Command } from 'commander';
import pc from 'picocolors';
import { getTrpcClient } from '../api/client';
import { getToolsTrpcClient, getTrpcClient } from '../api/client';
import { outputJson, printTable, truncate } from '../utils/format';
const SEARCH_TYPES = [
@@ -36,67 +36,283 @@ function renderResultGroup(type: string, items: any[]) {
}
export function registerSearchCommand(program: Command) {
program
.command('search <query>')
.description('Search across topics, agents, files, knowledge bases, and more')
const search = program
.command('search')
.description('Search across local resources or the web')
.option('-q, --query <query>', 'Search query')
.option('-w, --web', 'Search the web instead of local resources')
.option('-t, --type <type>', `Filter by type: ${SEARCH_TYPES.join(', ')}`)
.option('-L, --limit <n>', 'Results per type', '10')
.option('-e, --engines <engines>', 'Web search engines (comma-separated, requires --web)')
.option(
'-c, --categories <categories>',
'Web search categories (comma-separated, requires --web)',
)
.option(
'-T, --time-range <range>',
'Time range filter (e.g. day, week, month, year, requires --web)',
)
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(
async (
query: string,
options: { json?: string | boolean; limit?: string; type?: string },
) => {
if (options.type && !SEARCH_TYPES.includes(options.type as SearchType)) {
console.error(
`Invalid type: ${options.type}. Must be one of: ${SEARCH_TYPES.join(', ')}`,
);
process.exit(1);
}
const client = await getTrpcClient();
const input: { limitPerType?: number; query: string; type?: SearchType } = { query };
if (options.type) input.type = options.type as SearchType;
if (options.limit) input.limitPerType = Number.parseInt(options.limit, 10);
const result = await client.search.query.query(input);
if (options.json !== undefined) {
const fields = typeof options.json === 'string' ? options.json : undefined;
outputJson(result, fields);
async (options: {
categories?: string;
engines?: string;
json?: string | boolean;
limit?: string;
query?: string;
timeRange?: string;
type?: string;
web?: boolean;
}) => {
if (!options.query) {
search.help();
return;
}
// result is expected to be an object grouped by type or an array
if (Array.isArray(result)) {
if (result.length === 0) {
console.log('No results found.');
return;
}
// Group by type if available
const groups: Record<string, any[]> = {};
for (const item of result) {
const t = item.type || 'other';
if (!groups[t]) groups[t] = [];
groups[t].push(item);
}
for (const [type, items] of Object.entries(groups)) {
renderResultGroup(type, items);
}
} else if (result && typeof result === 'object') {
const groups = result as Record<string, any[]>;
let hasResults = false;
for (const [type, items] of Object.entries(groups)) {
if (Array.isArray(items) && items.length > 0) {
hasResults = true;
renderResultGroup(type, items);
}
}
if (!hasResults) {
console.log('No results found.');
}
if (options.web) {
await webSearch(options.query, options);
} else {
await localSearch(options.query, options);
}
},
);
// ── search view ──────────────────────────────────────
search
.command('view <target>')
.description('View details of a search result (URL for web results, or type:id for local)')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.option(
'-i, --impl <impls>',
'Crawler implementations for web URLs (comma-separated: browserless, exa, firecrawl, jina, naive, search1api, tavily)',
)
.action(
async (
target: string,
options: {
impl?: string;
json?: string | boolean;
},
) => {
if (target.startsWith('http://') || target.startsWith('https://')) {
await crawlView(target, options);
return;
}
await localView(target, options);
},
);
}
// ── local search ──────────────────────────────────────
async function localSearch(
query: string,
options: { json?: string | boolean; limit?: string; type?: string },
) {
if (options.type && !SEARCH_TYPES.includes(options.type as SearchType)) {
console.error(`Invalid type: ${options.type}. Must be one of: ${SEARCH_TYPES.join(', ')}`);
process.exit(1);
}
const client = await getTrpcClient();
const input: { limitPerType?: number; query: string; type?: SearchType } = { query };
if (options.type) input.type = options.type as SearchType;
if (options.limit) input.limitPerType = Number.parseInt(options.limit, 10);
const result = await client.search.query.query(input);
if (options.json !== undefined) {
const fields = typeof options.json === 'string' ? options.json : undefined;
outputJson(result, fields);
return;
}
if (Array.isArray(result)) {
if (result.length === 0) {
console.log('No results found.');
return;
}
const groups: Record<string, any[]> = {};
for (const item of result) {
const t = item.type || 'other';
if (!groups[t]) groups[t] = [];
groups[t].push(item);
}
for (const [type, items] of Object.entries(groups)) {
renderResultGroup(type, items);
}
} else if (result && typeof result === 'object') {
const groups = result as Record<string, any[]>;
let hasResults = false;
for (const [type, items] of Object.entries(groups)) {
if (Array.isArray(items) && items.length > 0) {
hasResults = true;
renderResultGroup(type, items);
}
}
if (!hasResults) {
console.log('No results found.');
}
}
}
// ── web search ────────────────────────────────────────
async function webSearch(
query: string,
options: {
categories?: string;
engines?: string;
json?: string | boolean;
timeRange?: string;
},
) {
const toolsClient = await getToolsTrpcClient();
const input: {
query: string;
searchCategories?: string[];
searchEngines?: string[];
searchTimeRange?: string;
} = { query };
if (options.engines) input.searchEngines = options.engines.split(',').map((s) => s.trim());
if (options.categories)
input.searchCategories = options.categories.split(',').map((s) => s.trim());
if (options.timeRange) input.searchTimeRange = options.timeRange;
const result = await toolsClient.search.webSearch.query(input);
if (options.json !== undefined) {
const fields = typeof options.json === 'string' ? options.json : undefined;
outputJson(result, fields);
return;
}
const res = result as any;
console.log(
pc.dim(
`Found ${res.resultNumbers ?? res.results?.length ?? 0} results in ${res.costTime ?? '?'}ms`,
),
);
if (!res.results || res.results.length === 0) {
console.log('No results found.');
return;
}
const rows = res.results.map((item: any) => [
truncate(item.title || '', 50),
truncate(item.url || '', 60),
item.score != null ? String(item.score) : '',
truncate(item.content || '', 60),
]);
printTable(rows, ['TITLE', 'URL', 'SCORE', 'CONTENT']);
}
// ── crawl view (for web URLs) ─────────────────────────
async function crawlView(url: string, options: { impl?: string; json?: string | boolean }) {
const toolsClient = await getToolsTrpcClient();
const input: {
impls?: ('browserless' | 'exa' | 'firecrawl' | 'jina' | 'naive' | 'search1api' | 'tavily')[];
urls: string[];
} = { urls: [url] };
if (options.impl) {
input.impls = options.impl.split(',').map((s) => s.trim()) as typeof input.impls;
}
const result = await toolsClient.search.crawlPages.mutate(input);
if (options.json !== undefined) {
const fields = typeof options.json === 'string' ? options.json : undefined;
outputJson(result, fields);
return;
}
const pages = Array.isArray(result) ? result : [result];
for (const page of pages) {
const p = page as any;
console.log();
console.log(pc.bold(pc.cyan(p.title || p.url || 'Untitled')));
if (p.url) console.log(pc.dim(p.url));
if (p.content) {
console.log();
console.log(p.content);
}
}
}
// ── local view (by type:id) ───────────────────────────
async function localView(target: string, options: { json?: string | boolean }) {
const sep = target.indexOf(':');
if (sep === -1) {
console.error(
'Invalid target. Use type:id (e.g. agent:abc123) for local resources, or a URL for web results.',
);
process.exit(1);
}
const type = target.slice(0, sep);
const id = target.slice(sep + 1);
if (!id) {
console.error('Missing id. Format: type:id');
process.exit(1);
}
const client = await getTrpcClient();
let result: any;
switch (type) {
case 'agent': {
result = await client.agent.getAgentConfigById.query({ agentId: id });
break;
}
case 'file': {
result = await client.file.getFileItemById.query({ id });
break;
}
case 'knowledgeBase': {
result = await client.knowledgeBase.getKnowledgeBaseById.query({ id });
break;
}
default: {
console.error(`View not supported for type "${type}". Supported: agent, file, knowledgeBase`);
process.exit(1);
}
}
if (!result) {
console.error(`${type} not found: ${id}`);
process.exit(1);
}
if (options.json !== undefined) {
const fields = typeof options.json === 'string' ? options.json : undefined;
outputJson(result, fields);
return;
}
const r = result as any;
console.log();
console.log(pc.bold(r.title || r.name || r.identifier || id));
if (r.description) console.log(pc.dim(r.description));
if (r.type) console.log(`Type: ${r.type}`);
if (r.createdAt) console.log(`Created: ${pc.dim(String(r.createdAt))}`);
if (r.updatedAt) console.log(`Updated: ${pc.dim(String(r.updatedAt))}`);
if (r.systemRole) {
console.log();
console.log(pc.cyan('System Role:'));
console.log(r.systemRole);
}
}

View File

@@ -3,13 +3,15 @@ import { createRequire } from 'node:module';
import { Command } from 'commander';
import { registerAgentCommand } from './commands/agent';
import { registerBotCommand } from './commands/bot';
import { registerConfigCommand } from './commands/config';
import { registerConnectCommand } from './commands/connect';
import { registerDeviceCommand } from './commands/device';
import { registerDocCommand } from './commands/doc';
import { registerEvalCommand } from './commands/eval';
import { registerFileCommand } from './commands/file';
import { registerGenerateCommand } from './commands/generate';
import { registerKbCommand } from './commands/kb';
import { registerEvalCommand } from './commands/eval';
import { registerLoginCommand } from './commands/login';
import { registerLogoutCommand } from './commands/logout';
import { registerMemoryCommand } from './commands/memory';
@@ -35,12 +37,14 @@ program
registerLoginCommand(program);
registerLogoutCommand(program);
registerConnectCommand(program);
registerDeviceCommand(program);
registerStatusCommand(program);
registerDocCommand(program);
registerSearchCommand(program);
registerKbCommand(program);
registerMemoryCommand(program);
registerAgentCommand(program);
registerBotCommand(program);
registerGenerateCommand(program);
registerFileCommand(program);
registerSkillCommand(program);