feat: add workspace skill root and root-based filtering to Skills Hub (#408)

- Add `workspace` skill root (~/.openclaw/workspace/skills) for
  workspace-local skill discovery alongside the existing 5 roots
- Make group cards clickable to filter the installed skills list by root
- Add `workspace` as valid target for skill creation and registry install
- Add `showAllRoots` i18n key to all 10 locale files

Closes #364
This commit is contained in:
nyk 2026-03-16 12:37:51 +07:00 committed by GitHub
parent 17bf0761f5
commit 6f1237704a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 53 additions and 18 deletions

View File

@ -1822,7 +1822,8 @@
"delete": "حذف", "delete": "حذف",
"save": "حفظ", "save": "حفظ",
"loadingSkillContent": "جارٍ تحميل SKILL.md...", "loadingSkillContent": "جارٍ تحميل SKILL.md...",
"noContent": "لا يوجد محتوى." "noContent": "لا يوجد محتوى.",
"showAllRoots": "Show all roots"
}, },
"agentSquadPhase3": { "agentSquadPhase3": {
"title": "Agent Squad", "title": "Agent Squad",

View File

@ -1822,7 +1822,8 @@
"delete": "Löschen", "delete": "Löschen",
"save": "Speichern", "save": "Speichern",
"loadingSkillContent": "SKILL.md wird geladen...", "loadingSkillContent": "SKILL.md wird geladen...",
"noContent": "Kein Inhalt." "noContent": "Kein Inhalt.",
"showAllRoots": "Show all roots"
}, },
"agentSquadPhase3": { "agentSquadPhase3": {
"title": "Agent Squad", "title": "Agent Squad",

View File

@ -1822,7 +1822,8 @@
"delete": "Delete", "delete": "Delete",
"save": "Save", "save": "Save",
"loadingSkillContent": "Loading SKILL.md...", "loadingSkillContent": "Loading SKILL.md...",
"noContent": "No content." "noContent": "No content.",
"showAllRoots": "Show all roots"
}, },
"agentSquadPhase3": { "agentSquadPhase3": {
"title": "Agent Squad", "title": "Agent Squad",

View File

@ -1822,7 +1822,8 @@
"delete": "Eliminar", "delete": "Eliminar",
"save": "Guardar", "save": "Guardar",
"loadingSkillContent": "Cargando SKILL.md...", "loadingSkillContent": "Cargando SKILL.md...",
"noContent": "Sin contenido." "noContent": "Sin contenido.",
"showAllRoots": "Show all roots"
}, },
"agentSquadPhase3": { "agentSquadPhase3": {
"title": "Agent Squad", "title": "Agent Squad",

View File

@ -1822,7 +1822,8 @@
"delete": "Supprimer", "delete": "Supprimer",
"save": "Enregistrer", "save": "Enregistrer",
"loadingSkillContent": "Chargement du SKILL.md...", "loadingSkillContent": "Chargement du SKILL.md...",
"noContent": "Aucun contenu." "noContent": "Aucun contenu.",
"showAllRoots": "Show all roots"
}, },
"agentSquadPhase3": { "agentSquadPhase3": {
"title": "Agent Squad", "title": "Agent Squad",

View File

@ -1822,7 +1822,8 @@
"delete": "削除", "delete": "削除",
"save": "保存", "save": "保存",
"loadingSkillContent": "SKILL.md を読み込み中...", "loadingSkillContent": "SKILL.md を読み込み中...",
"noContent": "コンテンツがありません。" "noContent": "コンテンツがありません。",
"showAllRoots": "Show all roots"
}, },
"agentSquadPhase3": { "agentSquadPhase3": {
"title": "Agent Squad", "title": "Agent Squad",

View File

@ -1822,7 +1822,8 @@
"delete": "삭제", "delete": "삭제",
"save": "저장", "save": "저장",
"loadingSkillContent": "SKILL.md 로드 중...", "loadingSkillContent": "SKILL.md 로드 중...",
"noContent": "내용 없음." "noContent": "내용 없음.",
"showAllRoots": "Show all roots"
}, },
"agentSquadPhase3": { "agentSquadPhase3": {
"title": "Agent Squad", "title": "Agent Squad",

View File

@ -1822,7 +1822,8 @@
"delete": "Excluir", "delete": "Excluir",
"save": "Salvar", "save": "Salvar",
"loadingSkillContent": "Carregando SKILL.md...", "loadingSkillContent": "Carregando SKILL.md...",
"noContent": "Sem conteúdo." "noContent": "Sem conteúdo.",
"showAllRoots": "Show all roots"
}, },
"agentSquadPhase3": { "agentSquadPhase3": {
"title": "Agent Squad", "title": "Agent Squad",

View File

@ -1822,7 +1822,8 @@
"delete": "Удалить", "delete": "Удалить",
"save": "Сохранить", "save": "Сохранить",
"loadingSkillContent": "Загрузка SKILL.md...", "loadingSkillContent": "Загрузка SKILL.md...",
"noContent": "Нет содержимого." "noContent": "Нет содержимого.",
"showAllRoots": "Show all roots"
}, },
"agentSquadPhase3": { "agentSquadPhase3": {
"title": "Agent Squad", "title": "Agent Squad",

View File

@ -692,7 +692,8 @@
"delete": "删除", "delete": "删除",
"save": "保存", "save": "保存",
"loadingSkillContent": "加载 SKILL.md...", "loadingSkillContent": "加载 SKILL.md...",
"noContent": "无内容。" "noContent": "无内容。",
"showAllRoots": "Show all roots"
}, },
"agentSquadPhase3": { "agentSquadPhase3": {
"title": "Agent Squad", "title": "Agent Squad",

View File

@ -9,7 +9,7 @@ import {
} from '@/lib/skill-registry' } from '@/lib/skill-registry'
const VALID_SOURCES: RegistrySource[] = ['clawhub', 'skills-sh', 'awesome-openclaw'] const VALID_SOURCES: RegistrySource[] = ['clawhub', 'skills-sh', 'awesome-openclaw']
const VALID_TARGETS = ['user-agents', 'user-codex', 'project-agents', 'project-codex', 'openclaw'] const VALID_TARGETS = ['user-agents', 'user-codex', 'project-agents', 'project-codex', 'openclaw', 'workspace']
/** /**
* GET /api/skills/registry?source=clawhub&q=terraform * GET /api/skills/registry?source=clawhub&q=terraform

View File

@ -88,6 +88,12 @@ function getSkillRoots(): SkillRoot[] {
const openclawState = process.env.OPENCLAW_STATE_DIR || process.env.OPENCLAW_HOME || join(home, '.openclaw') const openclawState = process.env.OPENCLAW_STATE_DIR || process.env.OPENCLAW_HOME || join(home, '.openclaw')
const openclawSkills = resolveSkillRoot('MC_SKILLS_OPENCLAW_DIR', join(openclawState, 'skills')) const openclawSkills = resolveSkillRoot('MC_SKILLS_OPENCLAW_DIR', join(openclawState, 'skills'))
roots.push({ source: 'openclaw', path: openclawSkills }) roots.push({ source: 'openclaw', path: openclawSkills })
// Add OpenClaw workspace-local skills (takes precedence when names conflict)
const workspaceDir = process.env.OPENCLAW_WORKSPACE_DIR || process.env.MISSION_CONTROL_WORKSPACE_DIR || join(openclawState, 'workspace')
const workspaceSkills = resolveSkillRoot('MC_SKILLS_WORKSPACE_DIR', join(workspaceDir, 'skills'))
roots.push({ source: 'workspace', path: workspaceSkills })
return roots return roots
} }

View File

@ -56,6 +56,7 @@ const SOURCE_LABELS: Record<string, string> = {
'project-agents': '.agents/skills (project)', 'project-agents': '.agents/skills (project)',
'project-codex': '.codex/skills (project)', 'project-codex': '.codex/skills (project)',
'openclaw': '~/.openclaw/skills (gateway)', 'openclaw': '~/.openclaw/skills (gateway)',
'workspace': '~/.openclaw/workspace/skills',
} }
export function SkillsPanel() { export function SkillsPanel() {
@ -65,6 +66,7 @@ export function SkillsPanel() {
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [activeRoot, setActiveRoot] = useState<string | null>(null)
const [selectedSkill, setSelectedSkill] = useState<SkillSummary | null>(null) const [selectedSkill, setSelectedSkill] = useState<SkillSummary | null>(null)
const [selectedContent, setSelectedContent] = useState<SkillContentResponse | null>(null) const [selectedContent, setSelectedContent] = useState<SkillContentResponse | null>(null)
const [draftContent, setDraftContent] = useState('') const [draftContent, setDraftContent] = useState('')
@ -142,14 +144,15 @@ export function SkillsPanel() {
}, [loadSkills]) }, [loadSkills])
const filtered = useMemo(() => { const filtered = useMemo(() => {
const list = skillsList || [] let list = skillsList || []
if (activeRoot) list = list.filter((s) => s.source === activeRoot)
const q = query.trim().toLowerCase() const q = query.trim().toLowerCase()
if (!q) return list if (!q) return list
return list.filter((skill) => { return list.filter((skill) => {
const haystack = `${skill.name} ${skill.source} ${skill.description || ''}`.toLowerCase() const haystack = `${skill.name} ${skill.source} ${skill.description || ''}`.toLowerCase()
return haystack.includes(q) return haystack.includes(q)
}) })
}, [skillsList, query]) }, [skillsList, query, activeRoot])
useEffect(() => { useEffect(() => {
if (!selectedSkill) return if (!selectedSkill) return
@ -513,6 +516,7 @@ export function SkillsPanel() {
{dashboardMode === 'full' && ( {dashboardMode === 'full' && (
<option value="openclaw">{SOURCE_LABELS['openclaw']}</option> <option value="openclaw">{SOURCE_LABELS['openclaw']}</option>
)} )}
<option value="workspace">{SOURCE_LABELS['workspace']}</option>
</select> </select>
<input <input
value={createName} value={createName}
@ -540,14 +544,28 @@ export function SkillsPanel() {
) : ( ) : (
<> <>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
{(skillGroups || []).filter(g => g.skills.length > 0 || ['user-agents', 'user-codex', 'openclaw'].includes(g.source)).map((group) => ( {activeRoot && (
<div key={group.source} className={`rounded-lg border bg-card p-3 ${ <button
group.source === 'openclaw' ? 'border-cyan-500/30' : 'border-border' onClick={() => setActiveRoot(null)}
}`}> className="col-span-full text-left text-2xs text-primary hover:underline"
>
{t('showAllRoots')}
</button>
)}
{(skillGroups || []).filter(g => g.skills.length > 0 || ['user-agents', 'user-codex', 'openclaw', 'workspace'].includes(g.source)).map((group) => (
<button
key={group.source}
onClick={() => setActiveRoot(activeRoot === group.source ? null : group.source)}
className={`rounded-lg border bg-card p-3 text-left transition-colors ${
activeRoot === group.source
? 'border-primary ring-1 ring-primary/30'
: group.source === 'openclaw' ? 'border-cyan-500/30 hover:border-cyan-500/50' : 'border-border hover:border-border/80'
}`}
>
<div className="text-xs font-medium text-muted-foreground">{SOURCE_LABELS[group.source] || group.source}</div> <div className="text-xs font-medium text-muted-foreground">{SOURCE_LABELS[group.source] || group.source}</div>
<div className="mt-1 text-lg font-semibold text-foreground">{group.skills.length}</div> <div className="mt-1 text-lg font-semibold text-foreground">{group.skills.length}</div>
<div className="mt-1 text-2xs text-muted-foreground truncate">{group.path}</div> <div className="mt-1 text-2xs text-muted-foreground truncate">{group.path}</div>
</div> </button>
))} ))}
</div> </div>
@ -641,6 +659,7 @@ export function SkillsPanel() {
{dashboardMode === 'full' && ( {dashboardMode === 'full' && (
<option value="openclaw">{SOURCE_LABELS['openclaw']}</option> <option value="openclaw">{SOURCE_LABELS['openclaw']}</option>
)} )}
<option value="workspace">{SOURCE_LABELS['workspace']}</option>
</select> </select>
</div> </div>
</div> </div>