nyk commited on
Commit
aab05eb
·
unverified ·
1 Parent(s): 394621e

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

Browse files

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

src/app/api/skills/route.ts CHANGED
@@ -94,6 +94,22 @@ function getSkillRoots(): SkillRoot[] {
94
  const workspaceSkills = resolveSkillRoot('MC_SKILLS_WORKSPACE_DIR', join(workspaceDir, 'skills'))
95
  roots.push({ source: 'workspace', path: workspaceSkills })
96
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  return roots
98
  }
99
 
@@ -259,6 +275,10 @@ export async function GET(request: NextRequest) {
259
  groupMap.set(root.source, { source: root.source, path: root.path, skills: [] })
260
  }
261
  for (const skill of dbSkills) {
 
 
 
 
262
  const group = groupMap.get(skill.source)
263
  if (group) group.skills.push(skill)
264
  }
 
94
  const workspaceSkills = resolveSkillRoot('MC_SKILLS_WORKSPACE_DIR', join(workspaceDir, 'skills'))
95
  roots.push({ source: 'workspace', path: workspaceSkills })
96
 
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
+
113
  return roots
114
  }
115
 
 
275
  groupMap.set(root.source, { source: root.source, path: root.path, skills: [] })
276
  }
277
  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
+ }
282
  const group = groupMap.get(skill.source)
283
  if (group) group.skills.push(skill)
284
  }
src/components/panels/skills-panel.tsx CHANGED
@@ -59,6 +59,15 @@ const SOURCE_LABELS: Record<string, string> = {
59
  'workspace': '~/.openclaw/workspace/skills',
60
  }
61
 
 
 
 
 
 
 
 
 
 
62
  export function SkillsPanel() {
63
  const t = useTranslations('skills')
64
  const { dashboardMode, skillsList, skillGroups, skillsTotal, setSkillsData } = useMissionControl()
@@ -552,17 +561,19 @@ export function SkillsPanel() {
552
  {t('showAllRoots')}
553
  </button>
554
  )}
555
- {(skillGroups || []).filter(g => g.skills.length > 0 || ['user-agents', 'user-codex', 'openclaw', 'workspace'].includes(g.source)).map((group) => (
556
  <button
557
  key={group.source}
558
  onClick={() => setActiveRoot(activeRoot === group.source ? null : group.source)}
559
  className={`rounded-lg border bg-card p-3 text-left transition-colors ${
560
  activeRoot === group.source
561
  ? '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'
 
 
563
  }`}
564
  >
565
- <div className="text-xs font-medium text-muted-foreground">{SOURCE_LABELS[group.source] || group.source}</div>
566
  <div className="mt-1 text-lg font-semibold text-foreground">{group.skills.length}</div>
567
  <div className="mt-1 text-2xs text-muted-foreground truncate">{group.path}</div>
568
  </button>
@@ -593,11 +604,13 @@ export function SkillsPanel() {
593
  <span className={`text-2xs rounded-full border px-2 py-0.5 ${
594
  skill.source === 'openclaw'
595
  ? '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'
 
 
599
  }`}>
600
- {SOURCE_LABELS[skill.source] || skill.source}
601
  </span>
602
  <Button variant="outline" size="xs" onClick={() => checkSecurity(skill)}>
603
  {t('scan')}
 
59
  'workspace': '~/.openclaw/workspace/skills',
60
  }
61
 
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
+
71
  export function SkillsPanel() {
72
  const t = useTranslations('skills')
73
  const { dashboardMode, skillsList, skillGroups, skillsTotal, setSkillsData } = useMissionControl()
 
561
  {t('showAllRoots')}
562
  </button>
563
  )}
564
+ {(skillGroups || []).filter(g => g.skills.length > 0 || ['user-agents', 'user-codex', 'openclaw', 'workspace'].includes(g.source) || g.source.startsWith('workspace-')).map((group) => (
565
  <button
566
  key={group.source}
567
  onClick={() => setActiveRoot(activeRoot === group.source ? null : group.source)}
568
  className={`rounded-lg border bg-card p-3 text-left transition-colors ${
569
  activeRoot === group.source
570
  ? 'border-primary ring-1 ring-primary/30'
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'
574
  }`}
575
  >
576
+ <div className="text-xs font-medium text-muted-foreground">{getSourceLabel(group.source)}</div>
577
  <div className="mt-1 text-lg font-semibold text-foreground">{group.skills.length}</div>
578
  <div className="mt-1 text-2xs text-muted-foreground truncate">{group.path}</div>
579
  </button>
 
604
  <span className={`text-2xs rounded-full border px-2 py-0.5 ${
605
  skill.source === 'openclaw'
606
  ? 'bg-cyan-500/10 text-cyan-400 border-cyan-500/30'
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'
612
  }`}>
613
+ {getSourceLabel(skill.source)}
614
  </span>
615
  <Button variant="outline" size="xs" onClick={() => checkSecurity(skill)}>
616
  {t('scan')}
src/lib/skill-sync.ts CHANGED
@@ -60,7 +60,7 @@ function getSkillRoots(): Array<{ source: string; path: string }> {
60
  const home = homedir()
61
  const cwd = process.cwd()
62
  const openclawState = process.env.OPENCLAW_STATE_DIR || process.env.OPENCLAW_HOME || join(home, '.openclaw')
63
- return [
64
  { source: 'user-agents', path: process.env.MC_SKILLS_USER_AGENTS_DIR || join(home, '.agents', 'skills') },
65
  { source: 'user-codex', path: process.env.MC_SKILLS_USER_CODEX_DIR || join(home, '.codex', 'skills') },
66
  { 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 }> {
68
  { source: 'openclaw', path: process.env.MC_SKILLS_OPENCLAW_DIR || join(openclawState, 'skills') },
69
  { 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') },
70
  ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  }
72
 
73
  // ---------------------------------------------------------------------------
@@ -128,6 +145,12 @@ export async function syncSkillsFromDisk(): Promise<{ ok: boolean; message: stri
128
 
129
  // Fetch current DB rows (only local sources, not registry-installed via slug)
130
  const localSources = ['user-agents', 'user-codex', 'project-agents', 'project-codex', 'openclaw', 'workspace']
 
 
 
 
 
 
131
  const dbRows = db.prepare(
132
  `SELECT * FROM skills WHERE source IN (${localSources.map(() => '?').join(',')})`
133
  ).all(...localSources) as SkillRow[]
 
60
  const home = homedir()
61
  const cwd = process.cwd()
62
  const openclawState = process.env.OPENCLAW_STATE_DIR || process.env.OPENCLAW_HOME || join(home, '.openclaw')
63
+ const roots: Array<{ source: string; path: string }> = [
64
  { source: 'user-agents', path: process.env.MC_SKILLS_USER_AGENTS_DIR || join(home, '.agents', 'skills') },
65
  { source: 'user-codex', path: process.env.MC_SKILLS_USER_CODEX_DIR || join(home, '.codex', 'skills') },
66
  { source: 'project-agents', path: process.env.MC_SKILLS_PROJECT_AGENTS_DIR || join(cwd, '.agents', 'skills') },
 
68
  { source: 'openclaw', path: process.env.MC_SKILLS_OPENCLAW_DIR || join(openclawState, 'skills') },
69
  { 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') },
70
  ]
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
88
  }
89
 
90
  // ---------------------------------------------------------------------------
 
145
 
146
  // Fetch current DB rows (only local sources, not registry-installed via slug)
147
  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
+ }
154
  const dbRows = db.prepare(
155
  `SELECT * FROM skills WHERE source IN (${localSources.map(() => '?').join(',')})`
156
  ).all(...localSources) as SkillRow[]