diff --git a/web/e2e/public-skill-detail-anonymous.spec.ts b/web/e2e/public-skill-detail-anonymous.spec.ts
index 56ba8885..61fcf3cf 100644
--- a/web/e2e/public-skill-detail-anonymous.spec.ts
+++ b/web/e2e/public-skill-detail-anonymous.spec.ts
@@ -11,6 +11,10 @@ function latestSeed(seed: PreparedSearchSeed) {
}
}
+function escapeRegExp(value: string) {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+}
+
let seeded: PreparedSearchSeed | undefined
test.describe('Public Skill Detail Anonymous Access (Real API)', () => {
@@ -40,7 +44,21 @@ test.describe('Public Skill Detail Anonymous Access (Real API)', () => {
await expect(page).not.toHaveURL(/\/login\?returnTo=/)
await expect(page.getByRole('heading', { name: current.skillName, exact: true })).toBeVisible()
await expect(page.getByText('Install', { exact: true })).toBeVisible()
- await expect(page.getByText(new RegExp(`npx clawhub install ${current.skill.slug}`))).toBeVisible()
+ const clawhubTarget = current.skill.namespace === 'global'
+ ? current.skill.slug
+ : `${current.skill.namespace}--${current.skill.slug}`
+ const skillhubNamespace = current.skill.namespace === 'global'
+ ? ''
+ : ` --namespace ${current.skill.namespace}`
+
+ await expect(page.getByRole('tab', { name: 'ClawHub CLI' })).toHaveAttribute('aria-selected', 'true')
+ await expect(page.getByText(new RegExp(`npx clawhub install ${escapeRegExp(clawhubTarget)} --registry`))).toBeVisible()
+ await expect(page.getByRole('tab', { name: 'SkillHub CLI' })).toBeVisible()
+
+ await page.getByRole('tab', { name: 'SkillHub CLI' }).click()
+
+ await expect(page.getByRole('tab', { name: 'SkillHub CLI' })).toHaveAttribute('aria-selected', 'true')
+ await expect(page.getByText(new RegExp(`npx @astron-team/skillhub@latest install ${escapeRegExp(current.skill.slug)}${escapeRegExp(skillhubNamespace)} --registry`))).toBeVisible()
await expect(page.getByRole('button', { name: 'Copy' }).first()).toBeVisible()
})
})
diff --git a/web/src/features/skill/install-command.test.ts b/web/src/features/skill/install-command.test.ts
index 1f4daf50..9649037c 100644
--- a/web/src/features/skill/install-command.test.ts
+++ b/web/src/features/skill/install-command.test.ts
@@ -1,7 +1,13 @@
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { afterEach, describe, expect, it, vi } from 'vitest'
-import { InstallCommand, buildInstallCommand, buildInstallTarget, getBaseUrl } from './install-command'
+import {
+ InstallCommand,
+ buildInstallCommand,
+ buildInstallTarget,
+ buildSkillhubInstallCommand,
+ getBaseUrl,
+} from './install-command'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
@@ -62,6 +68,18 @@ describe('install-command', () => {
)
})
+ it('builds a one-line SkillHub npx command for the global namespace', () => {
+ expect(buildSkillhubInstallCommand('global', 'my-skill', 'https://skill.xfyun.cn')).toBe(
+ 'npx @astron-team/skillhub@latest install my-skill --registry https://skill.xfyun.cn',
+ )
+ })
+
+ it('builds a one-line SkillHub npx command with namespace for team skills', () => {
+ expect(buildSkillhubInstallCommand('team-alpha', 'my-skill', 'https://skill.xfyun.cn')).toBe(
+ 'npx @astron-team/skillhub@latest install my-skill --namespace team-alpha --registry https://skill.xfyun.cn',
+ )
+ })
+
it('uses the runtime app base url when available', () => {
setMockWindow('https://app.example.com')
@@ -92,4 +110,19 @@ describe('install-command', () => {
expect(html).toContain('leading-relaxed')
expect(html).toContain('break-all')
})
+
+ it('renders ClawHub CLI as the default install method', () => {
+ setMockWindow('https://app.example.com')
+
+ const html = renderToStaticMarkup(createElement(InstallCommand, {
+ namespace: 'team-alpha',
+ slug: 'meeting-minutes-generator',
+ }))
+
+ expect(html).toContain('skillDetail.installMethodClawhub')
+ expect(html).toContain('skillDetail.installMethodSkillhub')
+ expect(html).toContain('aria-selected="true"')
+ expect(html).toContain('npx clawhub install team-alpha--meeting-minutes-generator --registry https://app.example.com')
+ expect(html).not.toContain('npx @astron-team/skillhub@latest install meeting-minutes-generator --namespace team-alpha --registry https://app.example.com')
+ })
})
diff --git a/web/src/features/skill/install-command.tsx b/web/src/features/skill/install-command.tsx
index f9989abb..3ed088c5 100644
--- a/web/src/features/skill/install-command.tsx
+++ b/web/src/features/skill/install-command.tsx
@@ -2,6 +2,7 @@ import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { Check, Copy } from 'lucide-react'
import { Button } from '@/shared/ui/button'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'
import { useCopyToClipboard } from '@/shared/lib/clipboard'
interface InstallCommandProps {
@@ -33,14 +34,19 @@ export function buildInstallCommand(namespace: string, slug: string, baseUrl: st
return `npx clawhub install ${installTarget} --registry ${baseUrl}`
}
-export function InstallCommand({ namespace, slug }: InstallCommandProps) {
+export function buildSkillhubInstallCommand(namespace: string, slug: string, baseUrl: string): string {
+ const namespaceArg = namespace === 'global' ? '' : ` --namespace ${namespace}`
+ return `npx @astron-team/skillhub@latest install ${slug}${namespaceArg} --registry ${baseUrl}`
+}
+
+interface CommandBlockProps {
+ command: string
+}
+
+function CommandBlock({ command }: CommandBlockProps) {
const { t } = useTranslation()
const [copied, copy] = useCopyToClipboard()
- const baseUrl = useMemo(() => getBaseUrl(), [])
-
- const command = useMemo(() => buildInstallCommand(namespace, slug, baseUrl), [baseUrl, namespace, slug])
-
const handleCopy = async () => {
try {
await copy(command)
@@ -70,3 +76,29 @@ export function InstallCommand({ namespace, slug }: InstallCommandProps) {
)
}
+
+export function InstallCommand({ namespace, slug }: InstallCommandProps) {
+ const { t } = useTranslation()
+ const baseUrl = useMemo(() => getBaseUrl(), [])
+ const clawhubCommand = useMemo(() => buildInstallCommand(namespace, slug, baseUrl), [baseUrl, namespace, slug])
+ const skillhubCommand = useMemo(() => buildSkillhubInstallCommand(namespace, slug, baseUrl), [baseUrl, namespace, slug])
+
+ return (
+