feat: discover and display per-agent workspace skill roots (#426)

Dynamically scan workspace-* directories under the openclaw state dir
to discover per-agent skill roots. Display them in the Skills Hub with
agent-specific labels and violet badge styling.

Closes #412
Supersedes #413
This commit is contained in:
nyk 2026-03-17 13:52:27 +07:00 committed by GitHub
parent 4671946e97
commit 2f2531f3d1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 64 additions and 8 deletions

View File

@ -94,6 +94,22 @@ function getSkillRoots(): SkillRoot[] {
const workspaceSkills = resolveSkillRoot('MC_SKILLS_WORKSPACE_DIR', join(workspaceDir, 'skills'))
roots.push({ source: 'workspace', path: workspaceSkills })
// Dynamic: scan for workspace-<agent> directories
try {
const { readdirSync, existsSync } = require('node:fs') as typeof import('node:fs')
const entries = readdirSync(openclawState) as string[]
for (const entry of entries) {
if (!entry.startsWith('workspace-')) continue
const skillsDir = join(openclawState, entry, 'skills')
if (existsSync(skillsDir)) {
const agentName = entry.replace('workspace-', '')
roots.push({ source: `workspace-${agentName}`, path: skillsDir })
}
}
} catch {
// openclawBase may not exist
}
return roots
}
@ -259,6 +275,10 @@ export async function GET(request: NextRequest) {
groupMap.set(root.source, { source: root.source, path: root.path, skills: [] })
}
for (const skill of dbSkills) {
// Dynamically add workspace-* groups not already in roots
if (!groupMap.has(skill.source) && skill.source.startsWith('workspace-')) {
groupMap.set(skill.source, { source: skill.source, path: '', skills: [] })
}
const group = groupMap.get(skill.source)
if (group) group.skills.push(skill)
}

View File

@ -59,6 +59,15 @@ const SOURCE_LABELS: Record<string, string> = {
'workspace': '~/.openclaw/workspace/skills',
}
function getSourceLabel(source: string): string {
if (SOURCE_LABELS[source]) return SOURCE_LABELS[source]
if (source.startsWith('workspace-')) {
const agentName = source.replace('workspace-', '')
return `${agentName} workspace`
}
return source
}
export function SkillsPanel() {
const t = useTranslations('skills')
const { dashboardMode, skillsList, skillGroups, skillsTotal, setSkillsData } = useMissionControl()
@ -552,17 +561,19 @@ export function SkillsPanel() {
{t('showAllRoots')}
</button>
)}
{(skillGroups || []).filter(g => g.skills.length > 0 || ['user-agents', 'user-codex', 'openclaw', 'workspace'].includes(g.source)).map((group) => (
{(skillGroups || []).filter(g => g.skills.length > 0 || ['user-agents', 'user-codex', 'openclaw', 'workspace'].includes(g.source) || g.source.startsWith('workspace-')).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'
: group.source === 'openclaw' ? 'border-cyan-500/30 hover:border-cyan-500/50'
: group.source.startsWith('workspace-') ? 'border-violet-500/30 hover:border-violet-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">{getSourceLabel(group.source)}</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>
</button>
@ -593,11 +604,13 @@ export function SkillsPanel() {
<span className={`text-2xs rounded-full border px-2 py-0.5 ${
skill.source === 'openclaw'
? 'bg-cyan-500/10 text-cyan-400 border-cyan-500/30'
: skill.source.startsWith('project-')
? 'bg-amber-500/10 text-amber-400 border-amber-500/30'
: 'border-border text-muted-foreground'
: skill.source.startsWith('workspace-')
? 'bg-violet-500/10 text-violet-400 border-violet-500/30'
: skill.source.startsWith('project-')
? 'bg-amber-500/10 text-amber-400 border-amber-500/30'
: 'border-border text-muted-foreground'
}`}>
{SOURCE_LABELS[skill.source] || skill.source}
{getSourceLabel(skill.source)}
</span>
<Button variant="outline" size="xs" onClick={() => checkSecurity(skill)}>
{t('scan')}

View File

@ -60,7 +60,7 @@ function getSkillRoots(): Array<{ source: string; path: string }> {
const home = homedir()
const cwd = process.cwd()
const openclawState = process.env.OPENCLAW_STATE_DIR || process.env.OPENCLAW_HOME || join(home, '.openclaw')
return [
const roots: Array<{ source: string; path: string }> = [
{ source: 'user-agents', path: process.env.MC_SKILLS_USER_AGENTS_DIR || join(home, '.agents', 'skills') },
{ source: 'user-codex', path: process.env.MC_SKILLS_USER_CODEX_DIR || join(home, '.codex', 'skills') },
{ source: 'project-agents', path: process.env.MC_SKILLS_PROJECT_AGENTS_DIR || join(cwd, '.agents', 'skills') },
@ -68,6 +68,23 @@ function getSkillRoots(): Array<{ source: string; path: string }> {
{ source: 'openclaw', path: process.env.MC_SKILLS_OPENCLAW_DIR || join(openclawState, 'skills') },
{ source: 'workspace', path: process.env.MC_SKILLS_WORKSPACE_DIR || join(process.env.OPENCLAW_WORKSPACE_DIR || process.env.MISSION_CONTROL_WORKSPACE_DIR || join(openclawState, 'workspace'), 'skills') },
]
// Dynamic: scan for workspace-<agent> directories
try {
const entries = readdirSync(openclawState)
for (const entry of entries) {
if (!entry.startsWith('workspace-')) continue
const skillsDir = join(openclawState, entry, 'skills')
if (existsSync(skillsDir)) {
const agentName = entry.replace('workspace-', '')
roots.push({ source: `workspace-${agentName}`, path: skillsDir })
}
}
} catch {
// openclawBase may not exist
}
return roots
}
// ---------------------------------------------------------------------------
@ -128,6 +145,12 @@ export async function syncSkillsFromDisk(): Promise<{ ok: boolean; message: stri
// Fetch current DB rows (only local sources, not registry-installed via slug)
const localSources = ['user-agents', 'user-codex', 'project-agents', 'project-codex', 'openclaw', 'workspace']
// Also include any dynamic workspace-* sources from disk
for (const s of diskSkills) {
if (s.source.startsWith('workspace-') && !localSources.includes(s.source)) {
localSources.push(s.source)
}
}
const dbRows = db.prepare(
`SELECT * FROM skills WHERE source IN (${localSources.map(() => '?').join(',')})`
).all(...localSources) as SkillRow[]