feat(settings): group agents and skills sidebar by subfolder (#464)
* feat(settings): group agents and skills by subfolder in sidebar - Server: fix getUserAgentPath() to walk subfolders so grouped agent layouts (e.g. agents/business/ceo.md) are correctly resolved - Store: add 'group' field to AgentWithExtras and DiscoveredSkill, parsed from file path at load time - UI: add collapsible SidebarGroup component with localStorage-persisted expand/collapse state - AgentsSidebar: render custom agents grouped by subfolder name - SkillsSidebar: render project/user skills grouped by domain folder - Ungrouped items (flat root) fall through and render normally * fix(settings): normalize group paths and add agent lookup caching parity --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
3cd6d051cb
commit
b2101acfbf
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ButtonLarge } from '@/components/ui/button-large';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -25,6 +25,7 @@ import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Agent } from '@opencode-ai/sdk/v2';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { SidebarGroup } from '@/components/sections/shared/SidebarGroup';
|
||||
|
||||
interface AgentsSidebarProps {
|
||||
onItemSelect?: () => void;
|
||||
@@ -307,6 +308,25 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
|
||||
const builtInAgents = visibleAgents.filter(isAgentBuiltIn);
|
||||
const customAgents = visibleAgents.filter((agent) => !isAgentBuiltIn(agent));
|
||||
|
||||
// Group custom agents by subfolder
|
||||
const { groupedCustomAgents, ungroupedCustomAgents } = useMemo(() => {
|
||||
const groups: Record<string, typeof customAgents> = {};
|
||||
const ungrouped: typeof customAgents = [];
|
||||
for (const agent of customAgents) {
|
||||
const ext = agent as { group?: string };
|
||||
if (ext.group) {
|
||||
if (!groups[ext.group]) groups[ext.group] = [];
|
||||
groups[ext.group].push(agent);
|
||||
} else {
|
||||
ungrouped.push(agent);
|
||||
}
|
||||
}
|
||||
const sortedGroups = Object.keys(groups)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.map((name) => ({ name, agents: groups[name] }));
|
||||
return { groupedCustomAgents: sortedGroups, ungroupedCustomAgents: ungrouped };
|
||||
}, [customAgents]);
|
||||
|
||||
return (
|
||||
<div className={cn('flex h-full flex-col', bgClass)}>
|
||||
<div className={cn('border-b px-3', isMobile ? 'mt-2 py-3' : 'py-3')}>
|
||||
@@ -363,7 +383,38 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
|
||||
<div className="px-2 pb-1.5 pt-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Custom Agents
|
||||
</div>
|
||||
{customAgents.map((agent) => (
|
||||
|
||||
{/* Grouped agents by subfolder */}
|
||||
{groupedCustomAgents.map(({ name: groupName, agents: groupAgents }) => (
|
||||
<SidebarGroup
|
||||
key={groupName}
|
||||
label={groupName}
|
||||
count={groupAgents.length}
|
||||
storageKey="agents"
|
||||
>
|
||||
{groupAgents.map((agent) => (
|
||||
<AgentListItem
|
||||
key={agent.name}
|
||||
agent={agent}
|
||||
isSelected={selectedAgentName === agent.name}
|
||||
onSelect={() => {
|
||||
setSelectedAgent(agent.name);
|
||||
onItemSelect?.();
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
}}
|
||||
onRename={() => handleOpenRenameDialog(agent)}
|
||||
onDelete={() => handleDeleteAgent(agent)}
|
||||
onDuplicate={() => handleDuplicateAgent(agent)}
|
||||
getAgentModeIcon={getAgentModeIcon}
|
||||
/>
|
||||
))}
|
||||
</SidebarGroup>
|
||||
))}
|
||||
|
||||
{/* Ungrouped agents (flat in root agents dir) */}
|
||||
{ungroupedCustomAgents.map((agent) => (
|
||||
<AgentListItem
|
||||
key={agent.name}
|
||||
agent={agent}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { RiArrowDownSLine } from '@remixicon/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SidebarGroupProps {
|
||||
/** Group display label (e.g. "business", "automation-ai") */
|
||||
label: string;
|
||||
/** Number of items in this group */
|
||||
count: number;
|
||||
/** Unique storage key prefix for persisting collapse state */
|
||||
storageKey: string;
|
||||
/** Whether to start expanded. Defaults to true. */
|
||||
defaultExpanded?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function getStorageKey(storageKey: string, label: string): string {
|
||||
return `opencode:sidebar-group:${storageKey}:${label}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapsible sidebar group with persisted expand/collapse state.
|
||||
* Used in Agents and Skills sidebars to group items by subfolder.
|
||||
*/
|
||||
export const SidebarGroup: React.FC<SidebarGroupProps> = ({
|
||||
label,
|
||||
count,
|
||||
storageKey,
|
||||
defaultExpanded = true,
|
||||
children,
|
||||
}) => {
|
||||
const key = getStorageKey(storageKey, label);
|
||||
|
||||
const [expanded, setExpanded] = useState<boolean>(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(key);
|
||||
if (stored !== null) return stored === 'true';
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
return defaultExpanded;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(key, String(expanded));
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
}, [key, expanded]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-1 rounded-md px-2 py-1 text-left',
|
||||
'text-xs font-semibold uppercase tracking-wide text-muted-foreground',
|
||||
'hover:bg-[var(--interactive-hover)] transition-colors duration-150',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
)}
|
||||
>
|
||||
<RiArrowDownSLine
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 flex-shrink-0 transition-transform duration-200',
|
||||
!expanded && '-rotate-90',
|
||||
)}
|
||||
/>
|
||||
<span className="flex-1 truncate">{label}</span>
|
||||
<span className="ml-1 tabular-nums opacity-60">{count}</span>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="mt-0.5 space-y-0.5 ml-2 pl-3 border-l-2 border-[var(--interactive-border)]">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -54,3 +54,4 @@ export { SettingsSidebarHeader } from './SettingsSidebarHeader';
|
||||
export { SettingsSidebarItem, type SettingsSidebarItemAction } from './SettingsSidebarItem';
|
||||
export { SettingsPageLayout } from './SettingsPageLayout';
|
||||
export { SettingsSection } from './SettingsSection';
|
||||
export { SidebarGroup } from './SidebarGroup';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ButtonLarge } from '@/components/ui/button-large';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -24,6 +24,7 @@ import { useDeviceInfo } from '@/lib/device';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { SidebarGroup } from '@/components/sections/shared/SidebarGroup';
|
||||
|
||||
interface SkillsSidebarProps {
|
||||
onItemSelect?: () => void;
|
||||
@@ -190,6 +191,27 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
const projectSkills = skills.filter((s) => s.scope === 'project');
|
||||
const userSkills = skills.filter((s) => s.scope === 'user');
|
||||
|
||||
// Helper: group a list of skills by their domain folder
|
||||
function groupSkillsByFolder(list: DiscoveredSkill[]) {
|
||||
const groups: Record<string, DiscoveredSkill[]> = {};
|
||||
const ungrouped: DiscoveredSkill[] = [];
|
||||
for (const skill of list) {
|
||||
if (skill.group) {
|
||||
if (!groups[skill.group]) groups[skill.group] = [];
|
||||
groups[skill.group].push(skill);
|
||||
} else {
|
||||
ungrouped.push(skill);
|
||||
}
|
||||
}
|
||||
const sortedGroups = Object.keys(groups)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.map((name) => ({ name, skills: groups[name] }));
|
||||
return { sortedGroups, ungrouped };
|
||||
}
|
||||
|
||||
const groupedProjectSkills = useMemo(() => groupSkillsByFolder(projectSkills), [projectSkills]);
|
||||
const groupedUserSkills = useMemo(() => groupSkillsByFolder(userSkills), [userSkills]);
|
||||
|
||||
return (
|
||||
<div className={cn('flex h-full flex-col', bgClass)}>
|
||||
<div className={cn('border-b px-3', isMobile ? 'mt-2 py-3' : 'py-3')}>
|
||||
@@ -221,7 +243,33 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
<div className="px-2 pb-1.5 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Project Skills
|
||||
</div>
|
||||
{projectSkills.map((skill) => (
|
||||
{groupedProjectSkills.sortedGroups.map(({ name: groupName, skills: groupSkills }) => (
|
||||
<SidebarGroup
|
||||
key={groupName}
|
||||
label={groupName}
|
||||
count={groupSkills.length}
|
||||
storageKey="project-skills"
|
||||
>
|
||||
{groupSkills.map((skill) => (
|
||||
<SkillListItem
|
||||
key={skill.name}
|
||||
skill={skill}
|
||||
isSelected={selectedSkillName === skill.name}
|
||||
onSelect={() => {
|
||||
setSelectedSkill(skill.name);
|
||||
onItemSelect?.();
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
}}
|
||||
onRename={() => handleOpenRenameDialog(skill)}
|
||||
onDelete={() => handleDeleteSkill(skill)}
|
||||
onDuplicate={() => handleDuplicateSkill(skill)}
|
||||
/>
|
||||
))}
|
||||
</SidebarGroup>
|
||||
))}
|
||||
{groupedProjectSkills.ungrouped.map((skill) => (
|
||||
<SkillListItem
|
||||
key={skill.name}
|
||||
skill={skill}
|
||||
@@ -246,7 +294,33 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
<div className="px-2 pb-1.5 pt-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
User Skills
|
||||
</div>
|
||||
{userSkills.map((skill) => (
|
||||
{groupedUserSkills.sortedGroups.map(({ name: groupName, skills: groupSkills }) => (
|
||||
<SidebarGroup
|
||||
key={groupName}
|
||||
label={groupName}
|
||||
count={groupSkills.length}
|
||||
storageKey="user-skills"
|
||||
>
|
||||
{groupSkills.map((skill) => (
|
||||
<SkillListItem
|
||||
key={skill.name}
|
||||
skill={skill}
|
||||
isSelected={selectedSkillName === skill.name}
|
||||
onSelect={() => {
|
||||
setSelectedSkill(skill.name);
|
||||
onItemSelect?.();
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
}}
|
||||
onRename={() => handleOpenRenameDialog(skill)}
|
||||
onDelete={() => handleDeleteSkill(skill)}
|
||||
onDuplicate={() => handleDuplicateSkill(skill)}
|
||||
/>
|
||||
))}
|
||||
</SidebarGroup>
|
||||
))}
|
||||
{groupedUserSkills.ungrouped.map((skill) => (
|
||||
<SkillListItem
|
||||
key={skill.name}
|
||||
skill={skill}
|
||||
|
||||
@@ -81,8 +81,26 @@ export type AgentWithExtras = Agent & {
|
||||
native?: boolean;
|
||||
hidden?: boolean;
|
||||
options?: { hidden?: boolean };
|
||||
scope?: AgentScope;
|
||||
/** Subfolder name parsed from file path, e.g. "business", "development" */
|
||||
group?: string;
|
||||
};
|
||||
|
||||
/** Parse the subfolder group name from an agent file path.
|
||||
* e.g. "~/.config/opencode/agents/business/ceo.md" → "business"
|
||||
* e.g. "~/.config/opencode/agents/ceo.md" → undefined
|
||||
*/
|
||||
function parseAgentGroup(path: string | null | undefined): string | undefined {
|
||||
if (!path) return undefined;
|
||||
const normalizedPath = path.replace(/\\/g, '/');
|
||||
const idx = normalizedPath.lastIndexOf('/agents/');
|
||||
if (idx === -1) return undefined;
|
||||
const relative = normalizedPath.substring(idx + '/agents/'.length);
|
||||
const parts = relative.split('/');
|
||||
// parts[0] = group, parts[1] = filename; need at least 2 parts
|
||||
return parts.length > 1 ? parts[0] : undefined;
|
||||
}
|
||||
|
||||
// Helper to check if agent is built-in (handles both SDK 'builtIn' and API 'native')
|
||||
export const isAgentBuiltIn = (agent: Agent): boolean => {
|
||||
const extended = agent as AgentWithExtras & { builtIn?: boolean };
|
||||
@@ -202,12 +220,16 @@ export const useAgentsStore = create<AgentsStore>()(
|
||||
?? sources.json?.scope;
|
||||
}
|
||||
|
||||
// Parse subfolder group from file path
|
||||
const mdPath: string | null | undefined = data.sources?.md?.path;
|
||||
const group = parseAgentGroup(mdPath);
|
||||
|
||||
if (scope === 'project' || scope === 'user') {
|
||||
return { ...agent, scope: scope as AgentScope };
|
||||
return { ...agent, scope: scope as AgentScope, group };
|
||||
}
|
||||
|
||||
// Explicitly set null scope if not found, to clear stale state
|
||||
return { ...agent, scope: undefined };
|
||||
return { ...agent, scope: undefined, group };
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[AgentsStore] Failed to fetch config for agent ${agent.name}:`, err);
|
||||
|
||||
@@ -64,6 +64,23 @@ export interface DiscoveredSkill {
|
||||
scope: SkillScope;
|
||||
source: SkillSource;
|
||||
description?: string;
|
||||
/** Domain folder parsed from file path, e.g. "automation-ai", "lark-ecosystem" */
|
||||
group?: string;
|
||||
}
|
||||
|
||||
/** Parse the domain group folder from a skill file path.
|
||||
* e.g. "~/.config/opencode/skills/automation-ai/ai-production/SKILL.md" → "automation-ai"
|
||||
* e.g. "~/.config/opencode/skills/theme-system/SKILL.md" → undefined (flat)
|
||||
*/
|
||||
function parseSkillGroup(path: string): string | undefined {
|
||||
const normalizedPath = path.replace(/\\/g, '/');
|
||||
const idx = normalizedPath.lastIndexOf('/skills/');
|
||||
if (idx === -1) return undefined;
|
||||
const relative = normalizedPath.substring(idx + '/skills/'.length);
|
||||
const parts = relative.split('/');
|
||||
// Grouped layout: <group>/<name>/SKILL.md → parts.length >= 3
|
||||
// Flat layout: <name>/SKILL.md → parts.length == 2
|
||||
return parts.length >= 3 ? parts[0] : undefined;
|
||||
}
|
||||
|
||||
// Raw skill response from API before transformation
|
||||
@@ -185,6 +202,7 @@ export const useSkillsStore = create<SkillsStore>()(
|
||||
scope: s.scope ?? 'user',
|
||||
source: s.source ?? 'opencode',
|
||||
description: s.sources?.md?.description || '',
|
||||
group: parseSkillGroup(s.path),
|
||||
}));
|
||||
|
||||
set({ skills, isLoading: false });
|
||||
|
||||
@@ -61,14 +61,104 @@ const getProjectAgentPath = (workingDirectory: string, agentName: string): strin
|
||||
return pluralPath;
|
||||
};
|
||||
|
||||
const getUserAgentPath = (agentName: string): string => {
|
||||
type AgentLookupCache = {
|
||||
userAgentIndexByName: Map<string, string>;
|
||||
userAgentLookupByName: Map<string, string | null>;
|
||||
userAgentIndexReady: boolean;
|
||||
userAgentIndexBuiltAt: number;
|
||||
};
|
||||
|
||||
const AGENT_LOOKUP_CACHE_TTL_MS = 1000;
|
||||
|
||||
const createAgentLookupCache = (): AgentLookupCache => ({
|
||||
userAgentIndexByName: new Map<string, string>(),
|
||||
userAgentLookupByName: new Map<string, string | null>(),
|
||||
userAgentIndexReady: false,
|
||||
userAgentIndexBuiltAt: 0,
|
||||
});
|
||||
|
||||
const globalAgentLookupCache = createAgentLookupCache();
|
||||
|
||||
const resetAgentLookupCache = (cache: AgentLookupCache): void => {
|
||||
cache.userAgentIndexByName.clear();
|
||||
cache.userAgentLookupByName.clear();
|
||||
cache.userAgentIndexReady = false;
|
||||
cache.userAgentIndexBuiltAt = 0;
|
||||
};
|
||||
|
||||
const buildUserAgentIndex = (cache: AgentLookupCache): void => {
|
||||
if (cache.userAgentIndexReady && Date.now() - cache.userAgentIndexBuiltAt < AGENT_LOOKUP_CACHE_TTL_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
cache.userAgentIndexByName.clear();
|
||||
cache.userAgentLookupByName.clear();
|
||||
cache.userAgentIndexReady = true;
|
||||
cache.userAgentIndexBuiltAt = Date.now();
|
||||
|
||||
if (!fs.existsSync(AGENT_DIR)) return;
|
||||
|
||||
const dirsToVisit: string[] = [AGENT_DIR];
|
||||
while (dirsToVisit.length > 0) {
|
||||
const dir = dirsToVisit.pop();
|
||||
if (!dir) continue;
|
||||
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
||||
const discoveredAgentName = entry.name.slice(0, -3);
|
||||
if (!cache.userAgentIndexByName.has(discoveredAgentName)) {
|
||||
cache.userAgentIndexByName.set(discoveredAgentName, path.join(dir, entry.name));
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
||||
const entry = entries[i];
|
||||
if (entry?.isDirectory()) {
|
||||
dirsToVisit.push(path.join(dir, entry.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getIndexedUserAgentPath = (agentName: string, cache: AgentLookupCache): string | null => {
|
||||
if (cache.userAgentLookupByName.has(agentName)) {
|
||||
return cache.userAgentLookupByName.get(agentName) || null;
|
||||
}
|
||||
|
||||
buildUserAgentIndex(cache);
|
||||
const found = cache.userAgentIndexByName.get(agentName) || null;
|
||||
cache.userAgentLookupByName.set(agentName, found);
|
||||
return found;
|
||||
};
|
||||
|
||||
const getUserAgentPath = (agentName: string, lookupCache: AgentLookupCache = globalAgentLookupCache): string => {
|
||||
const pluralPath = path.join(AGENT_DIR, `${agentName}.md`);
|
||||
|
||||
if (fs.existsSync(pluralPath)) return pluralPath;
|
||||
|
||||
const legacyPath = path.join(OPENCODE_CONFIG_DIR, 'agent', `${agentName}.md`);
|
||||
if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath;
|
||||
if (fs.existsSync(legacyPath)) return legacyPath;
|
||||
|
||||
const found = getIndexedUserAgentPath(agentName, lookupCache);
|
||||
if (found) return found;
|
||||
|
||||
return pluralPath;
|
||||
};
|
||||
|
||||
export const getAgentScope = (agentName: string, workingDirectory?: string): { scope: AgentScope | null; path: string | null } => {
|
||||
export const getAgentScope = (
|
||||
agentName: string,
|
||||
workingDirectory?: string,
|
||||
lookupCache: AgentLookupCache = globalAgentLookupCache
|
||||
): { scope: AgentScope | null; path: string | null } => {
|
||||
if (workingDirectory) {
|
||||
const projectPath = getProjectAgentPath(workingDirectory, agentName);
|
||||
if (fs.existsSync(projectPath)) {
|
||||
@@ -76,7 +166,7 @@ export const getAgentScope = (agentName: string, workingDirectory?: string): { s
|
||||
}
|
||||
}
|
||||
|
||||
const userPath = getUserAgentPath(agentName);
|
||||
const userPath = getUserAgentPath(agentName, lookupCache);
|
||||
if (fs.existsSync(userPath)) {
|
||||
return { scope: AGENT_SCOPE.USER, path: userPath };
|
||||
}
|
||||
@@ -84,8 +174,13 @@ export const getAgentScope = (agentName: string, workingDirectory?: string): { s
|
||||
return { scope: null, path: null };
|
||||
};
|
||||
|
||||
const getAgentWritePath = (agentName: string, workingDirectory?: string, requestedScope?: AgentScope): { scope: AgentScope; path: string } => {
|
||||
const existing = getAgentScope(agentName, workingDirectory);
|
||||
const getAgentWritePath = (
|
||||
agentName: string,
|
||||
workingDirectory?: string,
|
||||
requestedScope?: AgentScope,
|
||||
lookupCache: AgentLookupCache = globalAgentLookupCache
|
||||
): { scope: AgentScope; path: string } => {
|
||||
const existing = getAgentScope(agentName, workingDirectory, lookupCache);
|
||||
if (existing.path) {
|
||||
return { scope: existing.scope!, path: existing.path };
|
||||
}
|
||||
@@ -100,7 +195,7 @@ const getAgentWritePath = (agentName: string, workingDirectory?: string, request
|
||||
|
||||
return {
|
||||
scope: AGENT_SCOPE.USER,
|
||||
path: getUserAgentPath(agentName)
|
||||
path: getUserAgentPath(agentName, lookupCache)
|
||||
};
|
||||
};
|
||||
|
||||
@@ -703,6 +798,7 @@ export const createAgent = (agentName: string, config: Record<string, unknown>,
|
||||
const { prompt, scope: _ignored, ...frontmatter } = config as Record<string, unknown> & { prompt?: unknown; scope?: unknown };
|
||||
void _ignored; // Scope is only used for path determination
|
||||
writeMdFile(targetPath, frontmatter, typeof prompt === 'string' ? prompt : '');
|
||||
resetAgentLookupCache(globalAgentLookupCache);
|
||||
};
|
||||
|
||||
export const updateAgent = (agentName: string, updates: Record<string, unknown>, workingDirectory?: string) => {
|
||||
@@ -807,6 +903,10 @@ export const updateAgent = (agentName: string, updates: Record<string, unknown>,
|
||||
if (jsonModified) {
|
||||
writeConfig(config, jsonTarget.path || CONFIG_FILE);
|
||||
}
|
||||
|
||||
if (mdModified || isBuiltinOverride) {
|
||||
resetAgentLookupCache(globalAgentLookupCache);
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteAgent = (agentName: string, workingDirectory?: string) => {
|
||||
@@ -849,6 +949,8 @@ export const deleteAgent = (agentName: string, workingDirectory?: string) => {
|
||||
targetConfig.agent = agentMap;
|
||||
writeConfig(targetConfig, jsonTarget.path || CONFIG_FILE);
|
||||
}
|
||||
|
||||
resetAgentLookupCache(globalAgentLookupCache);
|
||||
};
|
||||
|
||||
export const getCommandSources = (commandName: string, workingDirectory?: string): ConfigSources => {
|
||||
|
||||
@@ -45,12 +45,80 @@ function getProjectAgentPath(workingDirectory, agentName) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user-level agent path
|
||||
* Create a per-request lookup cache for user-level agent path resolution.
|
||||
*/
|
||||
function getUserAgentPath(agentName) {
|
||||
function createAgentLookupCache() {
|
||||
return {
|
||||
userAgentIndexByName: new Map(),
|
||||
userAgentLookupByName: new Map(),
|
||||
userAgentIndexReady: false,
|
||||
};
|
||||
}
|
||||
|
||||
function buildUserAgentIndex(cache) {
|
||||
if (cache.userAgentIndexReady) return;
|
||||
cache.userAgentIndexReady = true;
|
||||
|
||||
if (!fs.existsSync(AGENT_DIR)) return;
|
||||
|
||||
const dirsToVisit = [AGENT_DIR];
|
||||
while (dirsToVisit.length > 0) {
|
||||
const dir = dirsToVisit.pop();
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
||||
const agentName = entry.name.slice(0, -3);
|
||||
if (!cache.userAgentIndexByName.has(agentName)) {
|
||||
cache.userAgentIndexByName.set(agentName, path.join(dir, entry.name));
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
||||
const entry = entries[i];
|
||||
if (entry.isDirectory()) {
|
||||
dirsToVisit.push(path.join(dir, entry.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getIndexedUserAgentPath(agentName, cache) {
|
||||
if (cache.userAgentLookupByName.has(agentName)) {
|
||||
return cache.userAgentLookupByName.get(agentName);
|
||||
}
|
||||
|
||||
buildUserAgentIndex(cache);
|
||||
const found = cache.userAgentIndexByName.get(agentName) || null;
|
||||
cache.userAgentLookupByName.set(agentName, found);
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user-level agent path — walks subfolders to support grouped layouts.
|
||||
* e.g. ~/.config/opencode/agents/business/ceo-diginno.md
|
||||
*/
|
||||
function getUserAgentPath(agentName, lookupCache = null) {
|
||||
// 1. Check flat path first (legacy / newly created agents)
|
||||
const pluralPath = path.join(AGENT_DIR, `${agentName}.md`);
|
||||
if (fs.existsSync(pluralPath)) return pluralPath;
|
||||
|
||||
const legacyPath = path.join(AGENT_DIR, '..', 'agent', `${agentName}.md`);
|
||||
if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath;
|
||||
if (fs.existsSync(legacyPath)) return legacyPath;
|
||||
|
||||
// 2. Lookup subfolders for grouped layout
|
||||
const cache = lookupCache || createAgentLookupCache();
|
||||
const found = getIndexedUserAgentPath(agentName, cache);
|
||||
if (found) return found;
|
||||
|
||||
// 3. Return expected flat path as default (for new agent creation)
|
||||
return pluralPath;
|
||||
}
|
||||
|
||||
@@ -58,7 +126,7 @@ function getUserAgentPath(agentName) {
|
||||
* Determine agent scope based on where the .md file exists
|
||||
* Priority: project level > user level > null (built-in only)
|
||||
*/
|
||||
function getAgentScope(agentName, workingDirectory) {
|
||||
function getAgentScope(agentName, workingDirectory, lookupCache = null) {
|
||||
if (workingDirectory) {
|
||||
const projectPath = getProjectAgentPath(workingDirectory, agentName);
|
||||
if (fs.existsSync(projectPath)) {
|
||||
@@ -66,7 +134,7 @@ function getAgentScope(agentName, workingDirectory) {
|
||||
}
|
||||
}
|
||||
|
||||
const userPath = getUserAgentPath(agentName);
|
||||
const userPath = getUserAgentPath(agentName, lookupCache);
|
||||
if (fs.existsSync(userPath)) {
|
||||
return { scope: AGENT_SCOPE.USER, path: userPath };
|
||||
}
|
||||
@@ -77,9 +145,9 @@ function getAgentScope(agentName, workingDirectory) {
|
||||
/**
|
||||
* Get the path where an agent should be written based on scope
|
||||
*/
|
||||
function getAgentWritePath(agentName, workingDirectory, requestedScope) {
|
||||
function getAgentWritePath(agentName, workingDirectory, requestedScope, lookupCache = null) {
|
||||
// For updates: check existing location first (project takes precedence)
|
||||
const existing = getAgentScope(agentName, workingDirectory);
|
||||
const existing = getAgentScope(agentName, workingDirectory, lookupCache);
|
||||
if (existing.path) {
|
||||
return existing;
|
||||
}
|
||||
@@ -95,7 +163,7 @@ function getAgentWritePath(agentName, workingDirectory, requestedScope) {
|
||||
|
||||
return {
|
||||
scope: AGENT_SCOPE.USER,
|
||||
path: getUserAgentPath(agentName)
|
||||
path: getUserAgentPath(agentName, lookupCache)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -104,7 +172,7 @@ function getAgentWritePath(agentName, workingDirectory, requestedScope) {
|
||||
* Priority: project .md > user .md > project JSON > user JSON
|
||||
* Returns: { source: 'md'|'json'|null, scope: 'project'|'user'|null, path: string|null }
|
||||
*/
|
||||
function getAgentPermissionSource(agentName, workingDirectory) {
|
||||
function getAgentPermissionSource(agentName, workingDirectory, lookupCache = null) {
|
||||
// Check project-level .md first
|
||||
if (workingDirectory) {
|
||||
const projectMdPath = getProjectAgentPath(workingDirectory, agentName);
|
||||
@@ -117,7 +185,7 @@ function getAgentPermissionSource(agentName, workingDirectory) {
|
||||
}
|
||||
|
||||
// Check user-level .md
|
||||
const userMdPath = getUserAgentPath(agentName);
|
||||
const userMdPath = getUserAgentPath(agentName, lookupCache);
|
||||
if (fs.existsSync(userMdPath)) {
|
||||
const { frontmatter } = parseMdFile(userMdPath);
|
||||
if (frontmatter.permission !== undefined) {
|
||||
@@ -215,11 +283,11 @@ function mergePermissionWithNonWildcards(newPermission, permissionSource, agentN
|
||||
return merged;
|
||||
}
|
||||
|
||||
function getAgentSources(agentName, workingDirectory) {
|
||||
function getAgentSources(agentName, workingDirectory, lookupCache = createAgentLookupCache()) {
|
||||
const projectPath = workingDirectory ? getProjectAgentPath(workingDirectory, agentName) : null;
|
||||
const projectExists = projectPath && fs.existsSync(projectPath);
|
||||
|
||||
const userPath = getUserAgentPath(agentName);
|
||||
const userPath = getUserAgentPath(agentName, lookupCache);
|
||||
const userExists = fs.existsSync(userPath);
|
||||
|
||||
const mdPath = projectExists ? projectPath : (userExists ? userPath : null);
|
||||
@@ -270,11 +338,11 @@ function getAgentSources(agentName, workingDirectory) {
|
||||
return sources;
|
||||
}
|
||||
|
||||
function getAgentConfig(agentName, workingDirectory) {
|
||||
function getAgentConfig(agentName, workingDirectory, lookupCache = createAgentLookupCache()) {
|
||||
const projectPath = workingDirectory ? getProjectAgentPath(workingDirectory, agentName) : null;
|
||||
const projectExists = projectPath && fs.existsSync(projectPath);
|
||||
|
||||
const userPath = getUserAgentPath(agentName);
|
||||
const userPath = getUserAgentPath(agentName, lookupCache);
|
||||
const userExists = fs.existsSync(userPath);
|
||||
|
||||
if (projectExists || userExists) {
|
||||
@@ -312,9 +380,10 @@ function getAgentConfig(agentName, workingDirectory) {
|
||||
|
||||
function createAgent(agentName, config, workingDirectory, scope) {
|
||||
ensureDirs();
|
||||
const lookupCache = createAgentLookupCache();
|
||||
|
||||
const projectPath = workingDirectory ? getProjectAgentPath(workingDirectory, agentName) : null;
|
||||
const userPath = getUserAgentPath(agentName);
|
||||
const userPath = getUserAgentPath(agentName, lookupCache);
|
||||
|
||||
if (projectPath && fs.existsSync(projectPath)) {
|
||||
throw new Error(`Agent ${agentName} already exists as project-level .md file`);
|
||||
@@ -350,8 +419,9 @@ function createAgent(agentName, config, workingDirectory, scope) {
|
||||
|
||||
function updateAgent(agentName, updates, workingDirectory) {
|
||||
ensureDirs();
|
||||
const lookupCache = createAgentLookupCache();
|
||||
|
||||
const { scope, path: mdPath } = getAgentWritePath(agentName, workingDirectory);
|
||||
const { scope, path: mdPath } = getAgentWritePath(agentName, workingDirectory, undefined, lookupCache);
|
||||
const mdExists = mdPath && fs.existsSync(mdPath);
|
||||
|
||||
const layers = readConfigLayers(workingDirectory);
|
||||
@@ -369,7 +439,7 @@ function updateAgent(agentName, updates, workingDirectory) {
|
||||
let targetScope = scope;
|
||||
|
||||
if (!mdExists && isBuiltinOverride) {
|
||||
targetPath = getUserAgentPath(agentName);
|
||||
targetPath = getUserAgentPath(agentName, lookupCache);
|
||||
targetScope = AGENT_SCOPE.USER;
|
||||
}
|
||||
|
||||
@@ -412,7 +482,7 @@ function updateAgent(agentName, updates, workingDirectory) {
|
||||
}
|
||||
|
||||
if (field === 'permission') {
|
||||
const permissionSource = getAgentPermissionSource(agentName, workingDirectory);
|
||||
const permissionSource = getAgentPermissionSource(agentName, workingDirectory, lookupCache);
|
||||
const newPermission = mergePermissionWithNonWildcards(value, permissionSource, agentName);
|
||||
|
||||
if (permissionSource.source === 'md') {
|
||||
@@ -510,6 +580,7 @@ function updateAgent(agentName, updates, workingDirectory) {
|
||||
}
|
||||
|
||||
function deleteAgent(agentName, workingDirectory) {
|
||||
const lookupCache = createAgentLookupCache();
|
||||
let deleted = false;
|
||||
|
||||
if (workingDirectory) {
|
||||
@@ -521,7 +592,7 @@ function deleteAgent(agentName, workingDirectory) {
|
||||
}
|
||||
}
|
||||
|
||||
const userPath = getUserAgentPath(agentName);
|
||||
const userPath = getUserAgentPath(agentName, lookupCache);
|
||||
if (fs.existsSync(userPath)) {
|
||||
fs.unlinkSync(userPath);
|
||||
console.log(`Deleted user-level agent .md file: ${userPath}`);
|
||||
|
||||
Reference in New Issue
Block a user