🐛 fix: lh command issue (#12949)

* fix command issue

* add run command UI

* fix API key

* add apikey page

* add apikey

* 🐛 fix: update apiKey model tests to use new sk-lh- prefix format

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Arvin Xu
2026-03-12 23:16:11 +08:00
committed by GitHub
parent 11ce1b2f9f
commit a5cc75c1ed
14 changed files with 259 additions and 44 deletions

View File

@@ -198,6 +198,7 @@
"builtins.lobe-skills.apiName.importFromMarket": "Import from Market", "builtins.lobe-skills.apiName.importFromMarket": "Import from Market",
"builtins.lobe-skills.apiName.importSkill": "Import Skill", "builtins.lobe-skills.apiName.importSkill": "Import Skill",
"builtins.lobe-skills.apiName.readReference": "Read Reference", "builtins.lobe-skills.apiName.readReference": "Read Reference",
"builtins.lobe-skills.apiName.runCommand": "Run Command",
"builtins.lobe-skills.apiName.searchSkill": "Search Skills", "builtins.lobe-skills.apiName.searchSkill": "Search Skills",
"builtins.lobe-skills.title": "Skills", "builtins.lobe-skills.title": "Skills",
"builtins.lobe-tools.apiName.activateTools": "Activate Tools", "builtins.lobe-tools.apiName.activateTools": "Activate Tools",

View File

@@ -194,6 +194,7 @@
"builtins.lobe-skills.apiName.importFromMarket": "从市场导入", "builtins.lobe-skills.apiName.importFromMarket": "从市场导入",
"builtins.lobe-skills.apiName.importSkill": "导入技能", "builtins.lobe-skills.apiName.importSkill": "导入技能",
"builtins.lobe-skills.apiName.readReference": "读取参考资料", "builtins.lobe-skills.apiName.readReference": "读取参考资料",
"builtins.lobe-skills.apiName.runCommand": "执行命令",
"builtins.lobe-skills.apiName.searchSkill": "搜索技能", "builtins.lobe-skills.apiName.searchSkill": "搜索技能",
"builtins.lobe-skills.title": "技能", "builtins.lobe-skills.title": "技能",
"builtins.lobe-tools.apiName.activateTools": "激活工具", "builtins.lobe-tools.apiName.activateTools": "激活工具",

View File

@@ -0,0 +1,60 @@
'use client';
import { type BuiltinInspectorProps } from '@lobechat/types';
import { createStaticStyles, cssVar, cx } from 'antd-style';
import { Check, X } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
import type { CommandResult, RunCommandParams } from '../../../types';
const styles = createStaticStyles(({ css }) => ({
statusIcon: css`
margin-block-end: -2px;
margin-inline-start: 4px;
`,
}));
export const RunCommandInspector = memo<BuiltinInspectorProps<RunCommandParams, CommandResult>>(
({ args, partialArgs, isArgumentsStreaming, isLoading, pluginState }) => {
const { t } = useTranslation('plugin');
const description = args?.description || partialArgs?.description;
if (isArgumentsStreaming) {
if (!description)
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-skills.apiName.runCommand')}</span>
</div>
);
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-skills.apiName.runCommand')}: </span>
<span className={highlightTextStyles.primary}>{description}</span>
</div>
);
}
return (
<div className={cx(inspectorTextStyles.root, isLoading && shinyTextStyles.shinyText)}>
<span style={{ marginInlineStart: 2 }}>
<span>{t('builtins.lobe-skills.apiName.runCommand')}: </span>
{description && <span className={highlightTextStyles.primary}>{description}</span>}
{isLoading ? null : pluginState?.success !== undefined ? (
pluginState.success ? (
<Check className={styles.statusIcon} color={cssVar.colorSuccess} size={14} />
) : (
<X className={styles.statusIcon} color={cssVar.colorError} size={14} />
)
) : null}
</span>
</div>
);
},
);
RunCommandInspector.displayName = 'RunCommandInspector';

View File

@@ -1,11 +1,13 @@
import { SkillsApiName } from '../../types'; import { SkillsApiName } from '../../types';
import { ExecScriptInspector } from './ExecScript'; import { ExecScriptInspector } from './ExecScript';
import { ReadReferenceInspector } from './ReadReference'; import { ReadReferenceInspector } from './ReadReference';
import { RunCommandInspector } from './RunCommand';
import { RunSkillInspector } from './RunSkill'; import { RunSkillInspector } from './RunSkill';
export const SkillsInspectors = { export const SkillsInspectors = {
[SkillsApiName.execScript]: ExecScriptInspector, [SkillsApiName.execScript]: ExecScriptInspector,
[SkillsApiName.readReference]: ReadReferenceInspector, [SkillsApiName.readReference]: ReadReferenceInspector,
[SkillsApiName.runCommand]: RunCommandInspector,
[SkillsApiName.activateSkill]: RunSkillInspector, [SkillsApiName.activateSkill]: RunSkillInspector,
// @deprecated skill id // @deprecated skill id
runSkill: RunSkillInspector, runSkill: RunSkillInspector,

View File

@@ -0,0 +1,55 @@
'use client';
import type { BuiltinRenderProps } from '@lobechat/types';
import { Block, Flexbox, Highlighter } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { memo } from 'react';
import type { CommandResult, RunCommandParams } from '../../../types';
const styles = createStaticStyles(({ css }) => ({
container: css`
overflow: hidden;
padding-inline: 8px 0;
`,
}));
const RunCommand = memo<BuiltinRenderProps<RunCommandParams, CommandResult>>(
({ args, content, pluginState }) => {
return (
<Flexbox className={styles.container} gap={8}>
<Block gap={8} padding={8} variant={'outlined'}>
<Highlighter
wrap
language={'sh'}
showLanguage={false}
style={{ maxHeight: 200, overflow: 'auto', paddingInline: 8 }}
variant={'borderless'}
>
{args?.command || ''}
</Highlighter>
{(pluginState?.output || content) && (
<Highlighter
wrap
language={'text'}
showLanguage={false}
style={{ maxHeight: 200, overflow: 'auto', paddingInline: 8 }}
variant={'filled'}
>
{pluginState?.output || content}
</Highlighter>
)}
{pluginState?.stderr && (
<Highlighter wrap language={'text'} showLanguage={false} variant={'filled'}>
{pluginState.stderr}
</Highlighter>
)}
</Block>
</Flexbox>
);
},
);
RunCommand.displayName = 'RunCommand';
export default RunCommand;

View File

@@ -1,10 +1,12 @@
import { SkillsApiName } from '../../types'; import { SkillsApiName } from '../../types';
import ExecScript from './ExecScript'; import ExecScript from './ExecScript';
import ReadReference from './ReadReference'; import ReadReference from './ReadReference';
import RunCommand from './RunCommand';
import RunSkill from './RunSkill'; import RunSkill from './RunSkill';
export const SkillsRenders = { export const SkillsRenders = {
[SkillsApiName.execScript]: ExecScript, [SkillsApiName.execScript]: ExecScript,
[SkillsApiName.readReference]: ReadReference, [SkillsApiName.readReference]: ReadReference,
[SkillsApiName.runCommand]: RunCommand,
[SkillsApiName.activateSkill]: RunSkill, [SkillsApiName.activateSkill]: RunSkill,
}; };

View File

@@ -45,7 +45,7 @@ describe('ApiKeyModel', () => {
expect(result.name).toBe(params.name); expect(result.name).toBe(params.name);
expect(result.enabled).toBe(params.enabled); expect(result.enabled).toBe(params.enabled);
expect(result.key).toBeDefined(); expect(result.key).toBeDefined();
expect(result.key).not.toMatch(/^lb-[\da-z]{16}$/); expect(result.key).not.toMatch(/^sk-lh-[\da-z]{16}$/);
expect(result.userId).toBe(userId); expect(result.userId).toBe(userId);
const apiKey = await serverDB.query.apiKeys.findFirst({ const apiKey = await serverDB.query.apiKeys.findFirst({
@@ -145,7 +145,7 @@ describe('ApiKeyModel', () => {
const keys = await apiKeyModel.query(); const keys = await apiKeyModel.query();
expect(keys).toHaveLength(2); expect(keys).toHaveLength(2);
expect(keys[0].key).toMatch(/^lb-[\da-z]{16}$/); expect(keys[0].key).toMatch(/^sk-lh-[\da-z]{16}$/);
}); });
it('should query API keys ordered by updatedAt desc', async () => { it('should query API keys ordered by updatedAt desc', async () => {
@@ -186,7 +186,7 @@ describe('ApiKeyModel', () => {
describe('findByKey', () => { describe('findByKey', () => {
it('should find API key by key value without custom hasher', async () => { it('should find API key by key value without custom hasher', async () => {
// Use a valid hex format key since validateApiKeyFormat checks for hex pattern // Use a valid hex format key since validateApiKeyFormat checks for hex pattern
const validKey = 'lb-abcdef0123456789'; const validKey = 'sk-lh-abcdef0123456789';
await serverDB.insert(apiKeys).values({ await serverDB.insert(apiKeys).values({
enabled: true, enabled: true,
key: validKey, key: validKey,
@@ -209,7 +209,7 @@ describe('ApiKeyModel', () => {
}); });
it('should return undefined for non-existent key', async () => { it('should return undefined for non-existent key', async () => {
const found = await apiKeyModel.findByKey('lb-0123456789abcdef'); const found = await apiKeyModel.findByKey('sk-lh-0123456789abcdef');
expect(found).toBeUndefined(); expect(found).toBeUndefined();
}); });
@@ -221,7 +221,7 @@ describe('ApiKeyModel', () => {
futureDate.setFullYear(futureDate.getFullYear() + 1); futureDate.setFullYear(futureDate.getFullYear() + 1);
// Use a valid hex format key // Use a valid hex format key
const validKey = 'lb-0123456789abcdef'; const validKey = 'sk-lh-0123456789abcdef';
await serverDB.insert(apiKeys).values({ await serverDB.insert(apiKeys).values({
enabled: true, enabled: true,
expiresAt: futureDate, expiresAt: futureDate,
@@ -238,7 +238,7 @@ describe('ApiKeyModel', () => {
it('should validate enabled key without expiration with valid hex format', async () => { it('should validate enabled key without expiration with valid hex format', async () => {
// Use a valid hex format key // Use a valid hex format key
const validKey = 'lb-fedcba9876543210'; const validKey = 'sk-lh-fedcba9876543210';
await serverDB.insert(apiKeys).values({ await serverDB.insert(apiKeys).values({
enabled: true, enabled: true,
key: validKey, key: validKey,
@@ -253,13 +253,13 @@ describe('ApiKeyModel', () => {
}); });
it('should reject non-existent key', async () => { it('should reject non-existent key', async () => {
const isValid = await apiKeyModel.validateKey('lb-0123456789abcdef'); const isValid = await apiKeyModel.validateKey('sk-lh-0123456789abcdef');
expect(isValid).toBe(false); expect(isValid).toBe(false);
}); });
it('should reject disabled key', async () => { it('should reject disabled key', async () => {
const validKey = 'lb-1111111111111111'; const validKey = 'sk-lh-1111111111111111';
await serverDB.insert(apiKeys).values({ await serverDB.insert(apiKeys).values({
enabled: false, enabled: false,
key: validKey, key: validKey,
@@ -277,7 +277,7 @@ describe('ApiKeyModel', () => {
const pastDate = new Date(); const pastDate = new Date();
pastDate.setFullYear(pastDate.getFullYear() - 1); pastDate.setFullYear(pastDate.getFullYear() - 1);
const validKey = 'lb-2222222222222222'; const validKey = 'sk-lh-2222222222222222';
await serverDB.insert(apiKeys).values({ await serverDB.insert(apiKeys).values({
enabled: true, enabled: true,
expiresAt: pastDate, expiresAt: pastDate,

View File

@@ -70,7 +70,7 @@ export const userAuthMiddleware = async (c: Context, next: Next) => {
if (bearerToken) { if (bearerToken) {
log('Bearer token received: %s...', bearerToken.slice(0, 10)); log('Bearer token received: %s...', bearerToken.slice(0, 10));
// Check if bearerToken matches API Key format (lb-{16 alphanumeric chars}) // Check if bearerToken matches API Key format (sk-lh-{16 alphanumeric chars})
const isApiKeyFormat = validateApiKeyFormat(bearerToken); const isApiKeyFormat = validateApiKeyFormat(bearerToken);
log('API Key format validation result: %s', isApiKeyFormat); log('API Key format validation result: %s', isApiKeyFormat);

View File

@@ -6,12 +6,12 @@ describe('apiKey', () => {
describe('generateApiKey', () => { describe('generateApiKey', () => {
it('should generate API key with correct format', () => { it('should generate API key with correct format', () => {
const apiKey = generateApiKey(); const apiKey = generateApiKey();
expect(apiKey).toMatch(/^lb-[\da-z]{16}$/); expect(apiKey).toMatch(/^sk-lh-[\da-z]{16}$/);
}); });
it('should generate API key with correct length', () => { it('should generate API key with correct length', () => {
const apiKey = generateApiKey(); const apiKey = generateApiKey();
expect(apiKey).toHaveLength(19); // 'lb-' (3) + 16 characters expect(apiKey).toHaveLength(22); // 'sk-lh-' (6) + 16 characters
}); });
it('should generate unique API keys', () => { it('should generate unique API keys', () => {
@@ -25,12 +25,12 @@ describe('apiKey', () => {
it('should start with lb- prefix', () => { it('should start with lb- prefix', () => {
const apiKey = generateApiKey(); const apiKey = generateApiKey();
expect(apiKey.startsWith('lb-')).toBe(true); expect(apiKey.startsWith('sk-lh-')).toBe(true);
}); });
it('should only contain lowercase alphanumeric characters after prefix', () => { it('should only contain lowercase alphanumeric characters after prefix', () => {
const apiKey = generateApiKey(); const apiKey = generateApiKey();
const randomPart = apiKey.slice(3); // Remove 'lb-' prefix const randomPart = apiKey.slice(6); // Remove 'sk-lh-' prefix
expect(randomPart).toMatch(/^[\da-z]+$/); expect(randomPart).toMatch(/^[\da-z]+$/);
}); });
}); });
@@ -67,52 +67,52 @@ describe('apiKey', () => {
describe('validateApiKeyFormat', () => { describe('validateApiKeyFormat', () => {
it('should validate correct API key format', () => { it('should validate correct API key format', () => {
const validKey = 'lb-1234567890abcdef'; const validKey = 'sk-lh-1234567890abcdef';
expect(validateApiKeyFormat(validKey)).toBe(true); expect(validateApiKeyFormat(validKey)).toBe(true);
}); });
it('should accept keys with only numbers', () => { it('should accept keys with only numbers', () => {
const validKey = 'lb-1234567890123456'; const validKey = 'sk-lh-1234567890123456';
expect(validateApiKeyFormat(validKey)).toBe(true); expect(validateApiKeyFormat(validKey)).toBe(true);
}); });
it('should accept keys with only lowercase letters', () => { it('should accept keys with only lowercase letters', () => {
const validKey = 'lb-abcdefabcdefabcd'; const validKey = 'sk-lh-abcdefabcdefabcd';
expect(validateApiKeyFormat(validKey)).toBe(true); expect(validateApiKeyFormat(validKey)).toBe(true);
}); });
it('should accept keys with mixed alphanumeric characters', () => { it('should accept keys with mixed alphanumeric characters', () => {
const validKey = 'lb-abc123def456789a'; const validKey = 'sk-lh-abc123def456789a';
expect(validateApiKeyFormat(validKey)).toBe(true); expect(validateApiKeyFormat(validKey)).toBe(true);
}); });
it('should reject keys without lb- prefix', () => { it('should reject keys without sk-lh- prefix', () => {
const invalidKey = '1234567890abcdef'; const invalidKey = '1234567890abcdef';
expect(validateApiKeyFormat(invalidKey)).toBe(false); expect(validateApiKeyFormat(invalidKey)).toBe(false);
}); });
it('should reject keys with wrong prefix', () => { it('should reject keys with wrong prefix', () => {
const invalidKey = 'lc-1234567890abcdef'; const invalidKey = 'lb-1234567890abcdef';
expect(validateApiKeyFormat(invalidKey)).toBe(false); expect(validateApiKeyFormat(invalidKey)).toBe(false);
}); });
it('should reject keys that are too short', () => { it('should reject keys that are too short', () => {
const invalidKey = 'lb-123456789abcde'; const invalidKey = 'sk-lh-123456789abcde';
expect(validateApiKeyFormat(invalidKey)).toBe(false); expect(validateApiKeyFormat(invalidKey)).toBe(false);
}); });
it('should reject keys that are too long', () => { it('should reject keys that are too long', () => {
const invalidKey = 'lb-1234567890abcdef0'; const invalidKey = 'sk-lh-1234567890abcdef0';
expect(validateApiKeyFormat(invalidKey)).toBe(false); expect(validateApiKeyFormat(invalidKey)).toBe(false);
}); });
it('should reject keys with uppercase letters', () => { it('should reject keys with uppercase letters', () => {
const invalidKey = 'lb-1234567890ABCDEF'; const invalidKey = 'sk-lh-1234567890ABCDEF';
expect(validateApiKeyFormat(invalidKey)).toBe(false); expect(validateApiKeyFormat(invalidKey)).toBe(false);
}); });
it('should reject keys with special characters', () => { it('should reject keys with special characters', () => {
const invalidKey = 'lb-1234567890abcd-f'; const invalidKey = 'sk-lh-1234567890abcd-f';
expect(validateApiKeyFormat(invalidKey)).toBe(false); expect(validateApiKeyFormat(invalidKey)).toBe(false);
}); });
@@ -121,19 +121,13 @@ describe('apiKey', () => {
}); });
it('should reject keys with spaces', () => { it('should reject keys with spaces', () => {
const invalidKey = 'lb-1234567890abcd f'; const invalidKey = 'sk-lh-1234567890abcd f';
expect(validateApiKeyFormat(invalidKey)).toBe(false); expect(validateApiKeyFormat(invalidKey)).toBe(false);
}); });
it('should validate generated keys', () => { it('should validate generated keys', () => {
// Generate a key and validate it
const generatedKey = generateApiKey(); const generatedKey = generateApiKey();
// Note: The validation pattern expects hex digits (0-9a-f), but generated keys use base36 (0-9a-z) expect(validateApiKeyFormat(generatedKey)).toBe(true);
// This test will fail if there are non-hex characters (g-z) in the generated key
const hasOnlyHexChars = /^lb-[\da-f]{16}$/.test(generatedKey);
if (hasOnlyHexChars) {
expect(validateApiKeyFormat(generatedKey)).toBe(true);
}
}); });
}); });
}); });

View File

@@ -3,7 +3,7 @@ let apiKeyCounter = 0;
/** /**
* Generate API Key * Generate API Key
* Format: lb-{random} * Format: sk-lh-{random}
* @returns Generated API Key * @returns Generated API Key
*/ */
export function generateApiKey(): string { export function generateApiKey(): string {
@@ -35,7 +35,7 @@ export function generateApiKey(): string {
randomPart = randomPart.slice(0, 16); randomPart = randomPart.slice(0, 16);
// Combine to form the final API Key // Combine to form the final API Key
return `lb-${randomPart}`; return `sk-lh-${randomPart}`;
} }
/** /**
@@ -54,7 +54,7 @@ export function isApiKeyExpired(expiresAt: Date | null): boolean {
* @returns Whether the key has a valid format * @returns Whether the key has a valid format
*/ */
export function validateApiKeyFormat(key: string): boolean { export function validateApiKeyFormat(key: string): boolean {
// Check format: lb-{random} // Check format: sk-lh-{random}
const pattern = /^lb-[\da-z]{16}$/; const pattern = /^sk-lh-[\da-z]{16}$/;
return pattern.test(key); return pattern.test(key);
} }

View File

@@ -199,6 +199,7 @@ export default {
'builtins.lobe-skills.apiName.importFromMarket': 'Import from Market', 'builtins.lobe-skills.apiName.importFromMarket': 'Import from Market',
'builtins.lobe-skills.apiName.importSkill': 'Import Skill', 'builtins.lobe-skills.apiName.importSkill': 'Import Skill',
'builtins.lobe-skills.apiName.readReference': 'Read Reference', 'builtins.lobe-skills.apiName.readReference': 'Read Reference',
'builtins.lobe-skills.apiName.runCommand': 'Run Command',
'builtins.lobe-skills.apiName.activateSkill': 'Activate Skill', 'builtins.lobe-skills.apiName.activateSkill': 'Activate Skill',
'builtins.lobe-skills.apiName.searchSkill': 'Search Skills', 'builtins.lobe-skills.apiName.searchSkill': 'Search Skills',
'builtins.lobe-skills.title': 'Skills', 'builtins.lobe-skills.title': 'Skills',

View File

@@ -38,6 +38,7 @@ import {
} from '@/store/serverConfig'; } from '@/store/serverConfig';
import { useUserStore } from '@/store/user'; import { useUserStore } from '@/store/user';
import { userProfileSelectors } from '@/store/user/slices/auth/selectors'; import { userProfileSelectors } from '@/store/user/slices/auth/selectors';
import { userGeneralSettingsSelectors } from '@/store/user/slices/settings/selectors';
export enum SettingsGroupKey { export enum SettingsGroupKey {
Account = 'account', Account = 'account',
@@ -71,6 +72,7 @@ export const useCategory = () => {
userProfileSelectors.nickName(s), userProfileSelectors.nickName(s),
]); ]);
const remoteServerUrl = useElectronStore(electronSyncSelectors.remoteServerUrl); const remoteServerUrl = useElectronStore(electronSyncSelectors.remoteServerUrl);
const isDevMode = useUserStore((s) => userGeneralSettingsSelectors.config(s).isDevMode);
// Process avatar URL for desktop environment // Process avatar URL for desktop environment
const avatarUrl = useMemo(() => { const avatarUrl = useMemo(() => {
@@ -96,11 +98,6 @@ export const useCategory = () => {
key: SettingsTabs.Stats, key: SettingsTabs.Stats,
label: tAuth('tab.stats'), label: tAuth('tab.stats'),
}, },
showApiKeyManage && {
icon: KeyIcon,
key: SettingsTabs.APIKey,
label: tAuth('tab.apikey'),
},
].filter(Boolean) as CategoryItem[]; ].filter(Boolean) as CategoryItem[];
groups.push({ groups.push({
@@ -232,6 +229,11 @@ export const useCategory = () => {
key: SettingsTabs.Storage, key: SettingsTabs.Storage,
label: t('tab.storage'), label: t('tab.storage'),
}, },
isDevMode && {
icon: KeyIcon,
key: SettingsTabs.APIKey,
label: tAuth('tab.apikey'),
},
{ {
icon: EllipsisIcon, icon: EllipsisIcon,
key: SettingsTabs.Advanced, key: SettingsTabs.Advanced,
@@ -260,6 +262,7 @@ export const useCategory = () => {
mobile, mobile,
showAiImage, showAiImage,
showApiKeyManage, showApiKeyManage,
isDevMode,
avatarUrl, avatarUrl,
username, username,
]); ]);

View File

@@ -0,0 +1,88 @@
import { describe, expect, it, vi } from 'vitest';
import { preprocessLhCommand } from '../preprocessLhCommand';
const mockSignUserJWT = vi.hoisted(() => vi.fn().mockResolvedValue('mock-jwt-token'));
vi.mock('@/libs/trpc/utils/internalJwt', () => ({
signUserJWT: mockSignUserJWT,
}));
vi.mock('@/envs/app', () => ({
appEnv: { APP_URL: 'https://app.lobehub.com' },
}));
vi.mock('@/utils/env', () => ({
isDev: false,
}));
const ENV_PREFIX = 'LOBEHUB_JWT=mock-jwt-token LOBEHUB_SERVER=https://app.lobehub.com';
describe('preprocessLhCommand', () => {
it('should return unchanged command for non-lh commands', async () => {
const result = await preprocessLhCommand('echo hello', 'user-1');
expect(result.isLhCommand).toBe(false);
expect(result.skipSkillLookup).toBe(false);
expect(result.command).toBe('echo hello');
});
it('should rewrite a single lh command', async () => {
const result = await preprocessLhCommand('lh topic list --json', 'user-1');
expect(result.isLhCommand).toBe(true);
expect(result.skipSkillLookup).toBe(true);
expect(result.command).toBe(`${ENV_PREFIX} npx -y @lobehub/cli topic list --json`);
});
it('should rewrite all lh commands chained with &&', async () => {
const cmd = 'lh topic list --page 1 && lh topic list --page 2 && echo "done"';
const result = await preprocessLhCommand(cmd, 'user-1');
expect(result.command).toBe(
`${ENV_PREFIX} npx -y @lobehub/cli topic list --page 1 && ${ENV_PREFIX} npx -y @lobehub/cli topic list --page 2 && echo "done"`,
);
});
it('should rewrite lh commands chained with ||', async () => {
const cmd = 'lh foo || lh bar';
const result = await preprocessLhCommand(cmd, 'user-1');
expect(result.command).toBe(
`${ENV_PREFIX} npx -y @lobehub/cli foo || ${ENV_PREFIX} npx -y @lobehub/cli bar`,
);
});
it('should rewrite lh commands chained with ;', async () => {
const cmd = 'lh foo; lh bar';
const result = await preprocessLhCommand(cmd, 'user-1');
expect(result.command).toBe(
`${ENV_PREFIX} npx -y @lobehub/cli foo; ${ENV_PREFIX} npx -y @lobehub/cli bar`,
);
});
it('should not replace lh inside other words', async () => {
const result = await preprocessLhCommand('echoalhough', 'user-1');
expect(result.isLhCommand).toBe(false);
expect(result.command).toBe('echoalhough');
});
it('should handle bare lh command', async () => {
const result = await preprocessLhCommand('lh', 'user-1');
expect(result.isLhCommand).toBe(true);
expect(result.command).toBe(`${ENV_PREFIX} npx -y @lobehub/cli`);
});
it('should return error when JWT signing fails', async () => {
mockSignUserJWT.mockRejectedValueOnce(new Error('sign failed'));
const result = await preprocessLhCommand('lh topic list', 'user-1');
expect(result.isLhCommand).toBe(true);
expect(result.error).toBe('Failed to authenticate for CLI execution');
expect(result.command).toBe('lh topic list');
});
});

View File

@@ -23,7 +23,9 @@ export const preprocessLhCommand = async (
command: string, command: string,
userId: string, userId: string,
): Promise<PreprocessResult> => { ): Promise<PreprocessResult> => {
const isLhCommand = /^lh\s/.test(command) || command === 'lh'; // Match `lh` at the start of the command or after shell operators (&&, ||, ;)
const lhPattern = /(?:^|&&|\|\||;)\s*lh(?:\s|$)/;
const isLhCommand = lhPattern.test(command);
if (!isLhCommand) { if (!isLhCommand) {
return { command, isLhCommand: false, skipSkillLookup: false }; return { command, isLhCommand: false, skipSkillLookup: false };
@@ -34,8 +36,14 @@ export const preprocessLhCommand = async (
const serverUrl = isDev ? 'https://app.lobehub.com' : appEnv.APP_URL; const serverUrl = isDev ? 'https://app.lobehub.com' : appEnv.APP_URL;
const rewritten = command.replace(/^lh/, 'npx -y @lobehub/cli'); const envPrefix = `LOBEHUB_JWT=${jwt} LOBEHUB_SERVER=${serverUrl}`;
const finalCommand = `LOBEHUB_JWT=${jwt} LOBEHUB_SERVER=${serverUrl} ${rewritten}`;
// Replace `lh` in all sub-commands separated by &&, ||, or ;
const rewritten = command.replaceAll(
/(^|&&|\|\||;)(\s*)lh(\s|$)/g,
`$1$2${envPrefix} npx -y @lobehub/cli$3`,
);
const finalCommand = rewritten;
log( log(
'Intercepted lh command for user %s, rewritten to: %s', 'Intercepted lh command for user %s, rewritten to: %s',