Spaces:
Sleeping
Sleeping
nyk commited on
feat: discover and display per-agent workspace skill roots (#426)
Browse filesDynamically 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 +20 -0
- src/components/panels/skills-panel.tsx +20 -7
- src/lib/skill-sync.ts +24 -1
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'
|
|
|
|
|
|
|
| 563 |
}`}
|
| 564 |
>
|
| 565 |
-
<div className="text-xs font-medium text-muted-foreground">{
|
| 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('
|
| 597 |
-
? 'bg-
|
| 598 |
-
: '
|
|
|
|
|
|
|
| 599 |
}`}>
|
| 600 |
-
{
|
| 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 |
-
|
| 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[]
|