Skip to content

Commit 2f2531f

Browse files
authored
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
1 parent 4671946 commit 2f2531f

3 files changed

Lines changed: 64 additions & 8 deletions

File tree

src/app/api/skills/route.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,22 @@ function getSkillRoots(): SkillRoot[] {
9494
const workspaceSkills = resolveSkillRoot('MC_SKILLS_WORKSPACE_DIR', join(workspaceDir, 'skills'))
9595
roots.push({ source: 'workspace', path: workspaceSkills })
9696

97+
// Dynamic: scan for workspace-<agent> directories
98+
try {
99+
const { readdirSync, existsSync } = require('node:fs') as typeof import('node:fs')
100+
const entries = readdirSync(openclawState) as string[]
101+
for (const entry of entries) {
102+
if (!entry.startsWith('workspace-')) continue
103+
const skillsDir = join(openclawState, entry, 'skills')
104+
if (existsSync(skillsDir)) {
105+
const agentName = entry.replace('workspace-', '')
106+
roots.push({ source: `workspace-${agentName}`, path: skillsDir })
107+
}
108+
}
109+
} catch {
110+
// openclawBase may not exist
111+
}
112+
97113
return roots
98114
}
99115

@@ -259,6 +275,10 @@ export async function GET(request: NextRequest) {
259275
groupMap.set(root.source, { source: root.source, path: root.path, skills: [] })
260276
}
261277
for (const skill of dbSkills) {
278+
// Dynamically add workspace-* groups not already in roots
279+
if (!groupMap.has(skill.source) && skill.source.startsWith('workspace-')) {
280+
groupMap.set(skill.source, { source: skill.source, path: '', skills: [] })
281+
}
262282
const group = groupMap.get(skill.source)
263283
if (group) group.skills.push(skill)
264284
}

src/components/panels/skills-panel.tsx

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,15 @@ const SOURCE_LABELS: Record<string, string> = {
5959
'workspace': '~/.openclaw/workspace/skills',
6060
}
6161

62+
function getSourceLabel(source: string): string {
63+
if (SOURCE_LABELS[source]) return SOURCE_LABELS[source]
64+
if (source.startsWith('workspace-')) {
65+
const agentName = source.replace('workspace-', '')
66+
return `${agentName} workspace`
67+
}
68+
return source
69+
}
70+
6271
export function SkillsPanel() {
6372
const t = useTranslations('skills')
6473
const { dashboardMode, skillsList, skillGroups, skillsTotal, setSkillsData } = useMissionControl()
@@ -552,17 +561,19 @@ export function SkillsPanel() {
552561
{t('showAllRoots')}
553562
</button>
554563
)}
555-
{(skillGroups || []).filter(g => g.skills.length > 0 || ['user-agents', 'user-codex', 'openclaw', 'workspace'].includes(g.source)).map((group) => (
564+
{(skillGroups || []).filter(g => g.skills.length > 0 || ['user-agents', 'user-codex', 'openclaw', 'workspace'].includes(g.source) || g.source.startsWith('workspace-')).map((group) => (
556565
<button
557566
key={group.source}
558567
onClick={() => setActiveRoot(activeRoot === group.source ? null : group.source)}
559568
className={`rounded-lg border bg-card p-3 text-left transition-colors ${
560569
activeRoot === group.source
561570
? 'border-primary ring-1 ring-primary/30'
562-
: group.source === 'openclaw' ? 'border-cyan-500/30 hover:border-cyan-500/50' : 'border-border hover:border-border/80'
571+
: group.source === 'openclaw' ? 'border-cyan-500/30 hover:border-cyan-500/50'
572+
: group.source.startsWith('workspace-') ? 'border-violet-500/30 hover:border-violet-500/50'
573+
: 'border-border hover:border-border/80'
563574
}`}
564575
>
565-
<div className="text-xs font-medium text-muted-foreground">{SOURCE_LABELS[group.source] || group.source}</div>
576+
<div className="text-xs font-medium text-muted-foreground">{getSourceLabel(group.source)}</div>
566577
<div className="mt-1 text-lg font-semibold text-foreground">{group.skills.length}</div>
567578
<div className="mt-1 text-2xs text-muted-foreground truncate">{group.path}</div>
568579
</button>
@@ -593,11 +604,13 @@ export function SkillsPanel() {
593604
<span className={`text-2xs rounded-full border px-2 py-0.5 ${
594605
skill.source === 'openclaw'
595606
? 'bg-cyan-500/10 text-cyan-400 border-cyan-500/30'
596-
: skill.source.startsWith('project-')
597-
? 'bg-amber-500/10 text-amber-400 border-amber-500/30'
598-
: 'border-border text-muted-foreground'
607+
: skill.source.startsWith('workspace-')
608+
? 'bg-violet-500/10 text-violet-400 border-violet-500/30'
609+
: skill.source.startsWith('project-')
610+
? 'bg-amber-500/10 text-amber-400 border-amber-500/30'
611+
: 'border-border text-muted-foreground'
599612
}`}>
600-
{SOURCE_LABELS[skill.source] || skill.source}
613+
{getSourceLabel(skill.source)}
601614
</span>
602615
<Button variant="outline" size="xs" onClick={() => checkSecurity(skill)}>
603616
{t('scan')}

src/lib/skill-sync.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,14 +60,31 @@ function getSkillRoots(): Array<{ source: string; path: string }> {
6060
const home = homedir()
6161
const cwd = process.cwd()
6262
const openclawState = process.env.OPENCLAW_STATE_DIR || process.env.OPENCLAW_HOME || join(home, '.openclaw')
63-
return [
63+
const roots: Array<{ source: string; path: string }> = [
6464
{ source: 'user-agents', path: process.env.MC_SKILLS_USER_AGENTS_DIR || join(home, '.agents', 'skills') },
6565
{ source: 'user-codex', path: process.env.MC_SKILLS_USER_CODEX_DIR || join(home, '.codex', 'skills') },
6666
{ source: 'project-agents', path: process.env.MC_SKILLS_PROJECT_AGENTS_DIR || join(cwd, '.agents', 'skills') },
6767
{ source: 'project-codex', path: process.env.MC_SKILLS_PROJECT_CODEX_DIR || join(cwd, '.codex', 'skills') },
6868
{ source: 'openclaw', path: process.env.MC_SKILLS_OPENCLAW_DIR || join(openclawState, 'skills') },
6969
{ 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') },
7070
]
71+
72+
// Dynamic: scan for workspace-<agent> directories
73+
try {
74+
const entries = readdirSync(openclawState)
75+
for (const entry of entries) {
76+
if (!entry.startsWith('workspace-')) continue
77+
const skillsDir = join(openclawState, entry, 'skills')
78+
if (existsSync(skillsDir)) {
79+
const agentName = entry.replace('workspace-', '')
80+
roots.push({ source: `workspace-${agentName}`, path: skillsDir })
81+
}
82+
}
83+
} catch {
84+
// openclawBase may not exist
85+
}
86+
87+
return roots
7188
}
7289

7390
// ---------------------------------------------------------------------------
@@ -128,6 +145,12 @@ export async function syncSkillsFromDisk(): Promise<{ ok: boolean; message: stri
128145

129146
// Fetch current DB rows (only local sources, not registry-installed via slug)
130147
const localSources = ['user-agents', 'user-codex', 'project-agents', 'project-codex', 'openclaw', 'workspace']
148+
// Also include any dynamic workspace-* sources from disk
149+
for (const s of diskSkills) {
150+
if (s.source.startsWith('workspace-') && !localSources.includes(s.source)) {
151+
localSources.push(s.source)
152+
}
153+
}
131154
const dbRows = db.prepare(
132155
`SELECT * FROM skills WHERE source IN (${localSources.map(() => '?').join(',')})`
133156
).all(...localSources) as SkillRow[]

0 commit comments

Comments
 (0)