feat: enhance agent management with project-level support

- Added support for project-level agents, allowing agents to be created, updated, and deleted within a specified working directory.
- Introduced `getAgentScope` function to determine the scope of agents based on their existence in project or user directories.
- Updated `getAgentSources`, `createAgent`, `updateAgent`, and `deleteAgent` functions to handle project-level paths and configurations.
- Enhanced logging to provide clearer information about agent operations, including scope and working directory.
- Refactored configuration reading and writing to accommodate project-specific configurations alongside user-level settings.
This commit is contained in:
Bohdan Triapitsyn
2025-12-29 22:42:10 +02:00
parent 0adcba854b
commit c4b9c18c24
10 changed files with 2849 additions and 1323 deletions
+35 -14
View File
@@ -1190,18 +1190,27 @@ async fn handle_agent_route(
req: Request<Body>,
name: String,
) -> Result<Response<Body>, StatusCode> {
// Get working directory for project-level agent detection
let working_directory = state.opencode.get_working_directory();
match method {
Method::GET => {
match opencode_config::get_agent_sources(&name).await {
Ok(sources) => Ok(json_response(
StatusCode::OK,
ConfigMetadataResponse {
name,
is_built_in: !sources.md.exists && !sources.json.exists,
scope: None,
sources,
},
)),
match opencode_config::get_agent_sources(&name, Some(&working_directory)).await {
Ok(sources) => {
let scope = sources.md.scope.clone().map(|s| match s {
opencode_config::Scope::User => opencode_config::CommandScope::User,
opencode_config::Scope::Project => opencode_config::CommandScope::Project,
});
Ok(json_response(
StatusCode::OK,
ConfigMetadataResponse {
name,
is_built_in: !sources.md.exists && !sources.json.exists,
scope,
sources,
},
))
}
Err(err) => {
error!("[desktop:config] Failed to read agent sources: {}", err);
Ok(config_error_response(
@@ -1216,8 +1225,17 @@ async fn handle_agent_route(
Ok(data) => data,
Err(resp) => return Ok(resp),
};
// Extract scope from payload if present
let scope = payload.get("scope")
.and_then(|v| v.as_str())
.and_then(|s| match s {
"project" => Some(opencode_config::AgentScope::Project),
"user" => Some(opencode_config::AgentScope::User),
_ => None,
});
match opencode_config::create_agent(&name, &payload).await {
match opencode_config::create_agent(&name, &payload, Some(&working_directory), scope).await {
Ok(()) => {
if let Err(resp) =
refresh_opencode_after_config_change(state, "agent creation").await
@@ -1253,7 +1271,7 @@ async fn handle_agent_route(
Err(resp) => return Ok(resp),
};
match opencode_config::update_agent(&name, &payload).await {
match opencode_config::update_agent(&name, &payload, Some(&working_directory)).await {
Ok(()) => {
if let Err(resp) =
refresh_opencode_after_config_change(state, "agent update").await
@@ -1283,7 +1301,7 @@ async fn handle_agent_route(
}
}
}
Method::DELETE => match opencode_config::delete_agent(&name).await {
Method::DELETE => match opencode_config::delete_agent(&name, Some(&working_directory)).await {
Ok(()) => {
if let Err(resp) =
refresh_opencode_after_config_change(state, "agent deletion").await
@@ -1329,7 +1347,10 @@ async fn handle_command_route(
Method::GET => {
match opencode_config::get_command_sources(&name, Some(&working_directory)).await {
Ok(sources) => {
let scope = sources.md.scope.clone();
let scope = sources.md.scope.clone().map(|s| match s {
opencode_config::Scope::User => opencode_config::CommandScope::User,
opencode_config::Scope::Project => opencode_config::CommandScope::Project,
});
Ok(json_response(
StatusCode::OK,
ConfigMetadataResponse {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -4,22 +4,21 @@ import { ButtonLarge } from '@/components/ui/button-large';
import { Input } from '@/components/ui/input';
import { toast } from 'sonner';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { RiAddLine, RiAiAgentFill, RiAiAgentLine, RiDeleteBinLine, RiFileCopyLine, RiMore2Line, RiRobot2Line, RiRobotLine } from '@remixicon/react';
import { useAgentsStore, isAgentBuiltIn, isAgentHidden } from '@/stores/useAgentsStore';
import { RiAddLine, RiAiAgentFill, RiAiAgentLine, RiDeleteBinLine, RiFileCopyLine, RiMore2Line, RiRobot2Line, RiRobotLine, RiRestartLine, RiEditLine } from '@remixicon/react';
import { useAgentsStore, isAgentBuiltIn, isAgentHidden, type AgentScope } from '@/stores/useAgentsStore';
import { useUIStore } from '@/stores/useUIStore';
import { useDeviceInfo } from '@/lib/device';
import { isVSCodeRuntime } from '@/lib/desktop';
@@ -28,309 +27,451 @@ import type { Agent } from '@opencode-ai/sdk';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
interface AgentsSidebarProps {
onItemSelect?: () => void;
onItemSelect?: () => void;
}
export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) => {
const [newAgentName, setNewAgentName] = React.useState('');
const [isCreateDialogOpen, setIsCreateDialogOpen] = React.useState(false);
const [renameDialogAgent, setRenameDialogAgent] = React.useState<Agent | null>(null);
const [renameNewName, setRenameNewName] = React.useState('');
const {
selectedAgentName,
agents,
setSelectedAgent,
deleteAgent,
loadAgents,
} = useAgentsStore();
const {
selectedAgentName,
agents,
setSelectedAgent,
setAgentDraft,
createAgent,
deleteAgent,
loadAgents,
} = useAgentsStore();
const { setSidebarOpen } = useUIStore();
const { isMobile } = useDeviceInfo();
const { setSidebarOpen } = useUIStore();
const { isMobile } = useDeviceInfo();
const [isDesktopRuntime, setIsDesktopRuntime] = React.useState<boolean>(() => {
if (typeof window === 'undefined') return false;
return typeof window.opencodeDesktop !== 'undefined';
const [isDesktopRuntime, setIsDesktopRuntime] = React.useState<boolean>(() => {
if (typeof window === 'undefined') return false;
return typeof window.opencodeDesktop !== 'undefined';
});
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
React.useEffect(() => {
if (typeof window === 'undefined') return;
setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined');
}, []);
React.useEffect(() => {
loadAgents();
}, [loadAgents]);
const bgClass = isDesktopRuntime
? 'bg-transparent'
: isVSCode
? 'bg-background'
: 'bg-sidebar';
const handleCreateNew = () => {
// Generate unique name
const baseName = 'new-agent';
let newName = baseName;
let counter = 1;
while (agents.some((a) => a.name === newName)) {
newName = `${baseName}-${counter}`;
counter++;
}
// Set draft and open the page for editing
setAgentDraft({ name: newName, scope: 'user' });
setSelectedAgent(newName);
if (isMobile) {
setSidebarOpen(false);
}
};
const handleDeleteAgent = async (agent: Agent) => {
if (isAgentBuiltIn(agent)) {
toast.error('Built-in agents cannot be deleted');
return;
}
if (window.confirm(`Are you sure you want to delete agent "${agent.name}"?`)) {
const success = await deleteAgent(agent.name);
if (success) {
toast.success(`Agent "${agent.name}" deleted successfully`);
} else {
toast.error('Failed to delete agent');
}
}
};
const handleResetAgent = async (agent: Agent) => {
if (!isAgentBuiltIn(agent)) {
return;
}
if (window.confirm(`Are you sure you want to reset agent "${agent.name}" to its default configuration?`)) {
const success = await deleteAgent(agent.name);
if (success) {
toast.success(`Agent "${agent.name}" reset to default`);
} else {
toast.error('Failed to reset agent');
}
}
};
const handleDuplicateAgent = (agent: Agent) => {
const baseName = agent.name;
let copyNumber = 1;
let newName = `${baseName}-copy`;
while (agents.some((a) => a.name === newName)) {
copyNumber++;
newName = `${baseName}-copy-${copyNumber}`;
}
// Set draft with prefilled values from source agent
const extAgent = agent as Agent & { scope?: AgentScope };
// Convert model object to string if needed (SDK type vs API type difference)
const modelStr = typeof agent.model === 'string'
? agent.model
: agent.model?.providerID && agent.model?.modelID
? `${agent.model.providerID}/${agent.model.modelID}`
: undefined;
const draftAgent = agent as Agent & { disable?: boolean };
setAgentDraft({
name: newName,
scope: extAgent.scope || 'user',
description: agent.description,
model: modelStr,
temperature: agent.temperature,
top_p: agent.topP,
prompt: agent.prompt,
mode: agent.mode,
tools: agent.tools,
permission: agent.permission,
disable: draftAgent.disable,
});
setSelectedAgent(newName);
if (isMobile) {
setSidebarOpen(false);
}
};
const handleOpenRenameDialog = (agent: Agent) => {
setRenameNewName(agent.name);
setRenameDialogAgent(agent);
};
const handleRenameAgent = async () => {
if (!renameDialogAgent) return;
const sanitizedName = renameNewName.trim().replace(/\s+/g, '-');
if (!sanitizedName) {
toast.error('Agent name is required');
return;
}
if (sanitizedName === renameDialogAgent.name) {
setRenameDialogAgent(null);
return;
}
if (agents.some((a) => a.name === sanitizedName)) {
toast.error('An agent with this name already exists');
return;
}
// Create new agent with new name and all existing config
// Convert model object to string if needed (SDK type vs API type difference)
const renameModelStr = typeof renameDialogAgent.model === 'string'
? renameDialogAgent.model
: renameDialogAgent.model?.providerID && renameDialogAgent.model?.modelID
? `${renameDialogAgent.model.providerID}/${renameDialogAgent.model.modelID}`
: undefined;
const renameExt = renameDialogAgent as Agent & { scope?: AgentScope; disable?: boolean };
const success = await createAgent({
name: sanitizedName,
description: renameDialogAgent.description,
model: renameModelStr,
temperature: renameDialogAgent.temperature,
top_p: renameDialogAgent.topP,
prompt: renameDialogAgent.prompt,
mode: renameDialogAgent.mode,
tools: renameDialogAgent.tools,
permission: renameDialogAgent.permission,
disable: renameExt.disable,
scope: renameExt.scope,
});
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
if (success) {
// Delete old agent
const deleteSuccess = await deleteAgent(renameDialogAgent.name);
if (deleteSuccess) {
toast.success(`Agent renamed to "${sanitizedName}"`);
setSelectedAgent(sanitizedName);
} else {
toast.error('Failed to remove old agent after rename');
}
} else {
toast.error('Failed to rename agent');
}
React.useEffect(() => {
if (typeof window === 'undefined') return;
setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined');
}, []);
setRenameDialogAgent(null);
};
React.useEffect(() => {
loadAgents();
}, [loadAgents]);
const getAgentModeIcon = (mode?: string) => {
switch (mode) {
case 'primary':
return <RiAiAgentLine className="h-3 w-3 text-primary" />;
case 'all':
return <RiAiAgentFill className="h-3 w-3 text-primary" />;
case 'subagent':
return <RiRobotLine className="h-3 w-3 text-primary" />;
default:
return null;
}
};
const bgClass = isDesktopRuntime
? 'bg-transparent'
: isVSCode
? 'bg-background'
: 'bg-sidebar';
// Filter out hidden agents (internal agents like title, compaction, summary)
const visibleAgents = agents.filter((agent) => !isAgentHidden(agent));
const builtInAgents = visibleAgents.filter(isAgentBuiltIn);
const customAgents = visibleAgents.filter((agent) => !isAgentBuiltIn(agent));
const handleCreateAgent = () => {
if (!newAgentName.trim()) {
toast.error('Agent name is required');
return;
}
if (agents.some((agent) => agent.name === newAgentName)) {
toast.error('An agent with this name already exists');
return;
}
setSelectedAgent(newAgentName);
setNewAgentName('');
setIsCreateDialogOpen(false);
if (isMobile) {
setSidebarOpen(false);
}
};
const handleDeleteAgent = async (agent: Agent) => {
if (isAgentBuiltIn(agent)) {
toast.error('Built-in agents cannot be deleted');
return;
}
if (window.confirm(`Are you sure you want to delete agent "${agent.name}"?`)) {
const success = await deleteAgent(agent.name);
if (success) {
toast.success(`Agent "${agent.name}" deleted successfully`);
} else {
toast.error('Failed to delete agent');
}
}
};
const handleDuplicateAgent = (agent: Agent) => {
const baseName = agent.name;
let copyNumber = 1;
let newName = `${baseName} Copy`;
while (agents.some((a) => a.name === newName)) {
copyNumber++;
newName = `${baseName} Copy ${copyNumber}`;
}
setSelectedAgent(newName);
setIsCreateDialogOpen(false);
if (isMobile) {
setSidebarOpen(false);
}
};
const getAgentModeIcon = (mode?: string) => {
switch (mode) {
case 'primary':
return <RiAiAgentLine className="h-3 w-3 text-primary" />;
case 'all':
return <RiAiAgentFill className="h-3 w-3 text-primary" />;
case 'subagent':
return <RiRobotLine className="h-3 w-3 text-primary" />;
default:
return null;
}
};
// Filter out hidden agents (internal agents like title, compaction, summary)
const visibleAgents = agents.filter((agent) => !isAgentHidden(agent));
const builtInAgents = visibleAgents.filter(isAgentBuiltIn);
const customAgents = visibleAgents.filter((agent) => !isAgentBuiltIn(agent));
return (
<div className={cn('flex h-full flex-col', bgClass)}>
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<div className={cn('border-b px-3', isMobile ? 'mt-2 py-3' : 'py-3')}>
<div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">Total {visibleAgents.length}</span>
<DialogTrigger asChild>
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 -my-1 text-muted-foreground">
<RiAddLine className="size-4" />
</Button>
</DialogTrigger>
</div>
</div>
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-1 px-3 py-2 overflow-x-hidden">
{visibleAgents.length === 0 ? (
<div className="py-12 px-4 text-center text-muted-foreground">
<RiRobot2Line className="mx-auto mb-3 h-10 w-10 opacity-50" />
<p className="typography-ui-label font-medium">No agents configured</p>
<p className="typography-meta mt-1 opacity-75">Use the + button above to create one</p>
</div>
) : (
<>
{builtInAgents.length > 0 && (
<>
<div className="px-2 pb-1.5 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Built-in Agents
</div>
{builtInAgents.map((agent) => (
<AgentListItem
key={agent.name}
agent={agent}
isSelected={selectedAgentName === agent.name}
onSelect={() => {
setSelectedAgent(agent.name);
onItemSelect?.();
if (isMobile) {
setSidebarOpen(false);
}
}}
onDuplicate={() => handleDuplicateAgent(agent)}
getAgentModeIcon={getAgentModeIcon}
/>
))}
</>
)}
{customAgents.length > 0 && (
<>
<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) => (
<AgentListItem
key={agent.name}
agent={agent}
isSelected={selectedAgentName === agent.name}
onSelect={() => {
setSelectedAgent(agent.name);
onItemSelect?.();
if (isMobile) {
setSidebarOpen(false);
}
}}
onDelete={() => handleDeleteAgent(agent)}
onDuplicate={() => handleDuplicateAgent(agent)}
getAgentModeIcon={getAgentModeIcon}
/>
))}
</>
)}
</>
)}
</ScrollableOverlay>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Agent</DialogTitle>
<DialogDescription>
Enter a unique name for your new agent
</DialogDescription>
</DialogHeader>
<Input
value={newAgentName}
onChange={(e) => setNewAgentName(e.target.value)}
placeholder="Agent name..."
className="text-foreground placeholder:text-muted-foreground"
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleCreateAgent();
}
}}
/>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setIsCreateDialogOpen(false)}
className="text-foreground hover:bg-muted hover:text-foreground"
>
Cancel
</Button>
<ButtonLarge onClick={handleCreateAgent}>
Create
</ButtonLarge>
</DialogFooter>
</DialogContent>
</Dialog>
return (
<div className={cn('flex h-full flex-col', bgClass)}>
<div className={cn('border-b px-3', isMobile ? 'mt-2 py-3' : 'py-3')}>
<div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">Total {visibleAgents.length}</span>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 -my-1 text-muted-foreground"
onClick={handleCreateNew}
>
<RiAddLine className="size-4" />
</Button>
</div>
);
</div>
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-1 px-3 py-2 overflow-x-hidden">
{visibleAgents.length === 0 ? (
<div className="py-12 px-4 text-center text-muted-foreground">
<RiRobot2Line className="mx-auto mb-3 h-10 w-10 opacity-50" />
<p className="typography-ui-label font-medium">No agents configured</p>
<p className="typography-meta mt-1 opacity-75">Use the + button above to create one</p>
</div>
) : (
<>
{builtInAgents.length > 0 && (
<>
<div className="px-2 pb-1.5 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Built-in Agents
</div>
{builtInAgents.map((agent) => (
<AgentListItem
key={agent.name}
agent={agent}
isSelected={selectedAgentName === agent.name}
onSelect={() => {
setSelectedAgent(agent.name);
onItemSelect?.();
if (isMobile) {
setSidebarOpen(false);
}
}}
onReset={() => handleResetAgent(agent)}
onDuplicate={() => handleDuplicateAgent(agent)}
getAgentModeIcon={getAgentModeIcon}
/>
))}
</>
)}
{customAgents.length > 0 && (
<>
<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) => (
<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}
/>
))}
</>
)}
</>
)}
</ScrollableOverlay>
{/* Rename Dialog */}
<Dialog open={renameDialogAgent !== null} onOpenChange={(open) => !open && setRenameDialogAgent(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Rename Agent</DialogTitle>
<DialogDescription>
Enter a new name for the agent "@{renameDialogAgent?.name}"
</DialogDescription>
</DialogHeader>
<Input
value={renameNewName}
onChange={(e) => setRenameNewName(e.target.value)}
placeholder="New agent name..."
className="text-foreground placeholder:text-muted-foreground"
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleRenameAgent();
}
}}
/>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setRenameDialogAgent(null)}
className="text-foreground hover:bg-muted hover:text-foreground"
>
Cancel
</Button>
<ButtonLarge onClick={handleRenameAgent}>
Rename
</ButtonLarge>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
interface AgentListItemProps {
agent: Agent;
isSelected: boolean;
onSelect: () => void;
onDelete?: () => void;
onDuplicate: () => void;
getAgentModeIcon: (mode?: string) => React.ReactNode;
agent: Agent;
isSelected: boolean;
onSelect: () => void;
onDelete?: () => void;
onReset?: () => void;
onRename?: () => void;
onDuplicate: () => void;
getAgentModeIcon: (mode?: string) => React.ReactNode;
}
const AgentListItem: React.FC<AgentListItemProps> = ({
agent,
isSelected,
onSelect,
onDelete,
onDuplicate,
getAgentModeIcon,
agent,
isSelected,
onSelect,
onDelete,
onReset,
onRename,
onDuplicate,
getAgentModeIcon,
}) => {
return (
<div
className={cn(
'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200',
isSelected ? 'dark:bg-accent/80 bg-primary/12' : 'hover:dark:bg-accent/40 hover:bg-primary/6'
)}
const extAgent = agent as Agent & { scope?: AgentScope };
return (
<div
className={cn(
'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200',
isSelected ? 'dark:bg-accent/80 bg-primary/12' : 'hover:dark:bg-accent/40 hover:bg-primary/6'
)}
>
<div className="flex min-w-0 flex-1 items-center">
<button
onClick={onSelect}
className="flex min-w-0 flex-1 flex-col gap-0 rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
tabIndex={0}
>
<div className="flex min-w-0 flex-1 items-center">
<button
onClick={onSelect}
className="flex min-w-0 flex-1 flex-col gap-0 rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
tabIndex={0}
>
<div className="flex items-center gap-1.5">
<span className="typography-ui-label font-normal truncate text-foreground">
{agent.name}
</span>
{getAgentModeIcon(agent.mode)}
</div>
<div className="flex items-center gap-1.5">
<span className="typography-ui-label font-normal truncate text-foreground">
{agent.name}
</span>
{getAgentModeIcon(agent.mode)}
{(extAgent.scope || isAgentBuiltIn(agent)) && (
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
{isAgentBuiltIn(agent) ? 'system' : extAgent.scope}
</span>
)}
</div>
{agent.description && (
<div className="typography-micro text-muted-foreground/60 truncate leading-tight">
{agent.description}
</div>
)}
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"
>
<RiMore2Line className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit min-w-20">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDuplicate();
}}
>
<RiFileCopyLine className="h-4 w-4 mr-px" />
Duplicate
</DropdownMenuItem>
{!isAgentBuiltIn(agent) && onDelete && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="text-destructive focus:text-destructive"
>
<RiDeleteBinLine className="h-4 w-4 mr-px" />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
{agent.description && (
<div className="typography-micro text-muted-foreground/60 truncate leading-tight">
{agent.description}
</div>
</div>
);
)}
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"
>
<RiMore2Line className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit min-w-20">
{onRename && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onRename();
}}
>
<RiEditLine className="h-4 w-4 mr-px" />
Rename
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDuplicate();
}}
>
<RiFileCopyLine className="h-4 w-4 mr-px" />
Duplicate
</DropdownMenuItem>
{onReset && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onReset();
}}
>
<RiRestartLine className="h-4 w-4 mr-px" />
Reset
</DropdownMenuItem>
)}
{onDelete && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="text-destructive focus:text-destructive"
>
<RiDeleteBinLine className="h-4 w-4 mr-px" />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
};
+92 -5
View File
@@ -12,6 +12,26 @@ import {
import { getSafeStorage } from "./utils/safeStorage";
import { useConfigStore } from "@/stores/useConfigStore";
// Note: useDirectoryStore cannot be imported at top level to avoid circular dependency
// useDirectoryStore -> useAgentsStore (for refreshAfterOpenCodeRestart)
// useAgentsStore -> useDirectoryStore (for currentDirectory)
// Instead we access it from the window object where it's exposed
const getCurrentDirectory = (): string | null => {
// Try to get from window if store is already loaded
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const store = (window as any).__zustand_directory_store__;
if (store) {
return store.getState().currentDirectory;
}
} catch {
// ignore
}
return null;
};
export type AgentScope = 'user' | 'project';
export interface AgentConfig {
name: string;
description?: string;
@@ -22,12 +42,16 @@ export interface AgentConfig {
mode?: "primary" | "subagent" | "all";
tools?: Record<string, boolean>;
permission?: {
edit?: "allow" | "ask" | "deny" | "full";
edit?: "allow" | "ask" | "deny";
bash?: "allow" | "ask" | "deny" | Record<string, "allow" | "ask" | "deny">;
skill?: "allow" | "ask" | "deny" | Record<string, "allow" | "ask" | "deny">;
webfetch?: "allow" | "ask" | "deny";
doom_loop?: "allow" | "ask" | "deny";
external_directory?: "allow" | "ask" | "deny";
};
disable?: boolean;
scope?: AgentScope;
}
// Extended Agent type for API properties not in SDK types
@@ -61,13 +85,36 @@ const SLOW_HEALTH_POLL_BASE_MS = 800;
const SLOW_HEALTH_POLL_INCREMENT_MS = 200;
const SLOW_HEALTH_POLL_MAX_MS = 2000;
export interface AgentDraft {
name: string;
scope: AgentScope;
description?: string;
model?: string | null;
temperature?: number;
top_p?: number;
prompt?: string;
mode?: "primary" | "subagent" | "all";
tools?: Record<string, boolean>;
permission?: {
edit?: "allow" | "ask" | "deny";
bash?: "allow" | "ask" | "deny" | Record<string, "allow" | "ask" | "deny">;
skill?: "allow" | "ask" | "deny" | Record<string, "allow" | "ask" | "deny">;
webfetch?: "allow" | "ask" | "deny";
doom_loop?: "allow" | "ask" | "deny";
external_directory?: "allow" | "ask" | "deny";
};
disable?: boolean;
}
interface AgentsStore {
selectedAgentName: string | null;
agents: Agent[];
isLoading: boolean;
agentDraft: AgentDraft | null;
setSelectedAgent: (name: string | null) => void;
setAgentDraft: (draft: AgentDraft | null) => void;
loadAgents: () => Promise<boolean>;
createAgent: (config: AgentConfig) => Promise<boolean>;
updateAgent: (name: string, config: Partial<AgentConfig>) => Promise<boolean>;
@@ -91,11 +138,16 @@ export const useAgentsStore = create<AgentsStore>()(
selectedAgentName: null,
agents: [],
isLoading: false,
agentDraft: null,
setSelectedAgent: (name: string | null) => {
set({ selectedAgentName: name });
},
setAgentDraft: (draft: AgentDraft | null) => {
set({ agentDraft: draft });
},
loadAgents: async () => {
set({ isLoading: true });
const previousAgents = get().agents;
@@ -104,7 +156,29 @@ export const useAgentsStore = create<AgentsStore>()(
for (let attempt = 0; attempt < 3; attempt++) {
try {
const agents = await opencodeClient.listAgents();
set({ agents, isLoading: false });
// Fetch scope info for each agent
const currentDirectory = getCurrentDirectory();
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
const agentsWithScope = await Promise.all(
agents.map(async (agent) => {
try {
const response = await fetch(`/api/config/agents/${encodeURIComponent(agent.name)}${queryParams}`);
if (response.ok) {
const data = await response.json();
// Handle web/desktop response formats; fall back to JSON scope if md scope missing
const scope = data.scope ?? data.sources?.md?.scope ?? data.sources?.json?.scope;
return { ...agent, scope: scope as AgentScope | undefined };
}
} catch (err) {
console.error(`[AgentsStore] Failed to fetch scope for agent "${agent.name}":`, err);
}
return agent;
})
);
set({ agents: agentsWithScope, isLoading: false });
return true;
} catch (error) {
lastError = error;
@@ -136,10 +210,15 @@ export const useAgentsStore = create<AgentsStore>()(
if (config.tools && Object.keys(config.tools).length > 0) agentConfig.tools = config.tools;
if (config.permission) agentConfig.permission = config.permission;
if (config.disable !== undefined) agentConfig.disable = config.disable;
if (config.scope) agentConfig.scope = config.scope;
console.log('[AgentsStore] Agent config to save:', agentConfig);
const response = await fetch(`/api/config/agents/${encodeURIComponent(config.name)}`, {
// Get current directory for project-level agent support
const currentDirectory = getCurrentDirectory();
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
const response = await fetch(`/api/config/agents/${encodeURIComponent(config.name)}${queryParams}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(agentConfig)
@@ -199,7 +278,11 @@ export const useAgentsStore = create<AgentsStore>()(
console.log('[AgentsStore] Agent config to update:', agentConfig);
const response = await fetch(`/api/config/agents/${encodeURIComponent(name)}`, {
// Get current directory for project-level agent support
const currentDirectory = getCurrentDirectory();
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
const response = await fetch(`/api/config/agents/${encodeURIComponent(name)}${queryParams}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(agentConfig)
@@ -242,7 +325,11 @@ export const useAgentsStore = create<AgentsStore>()(
startConfigUpdate("Deleting agent configuration…");
let requiresReload = false;
try {
const response = await fetch(`/api/config/agents/${encodeURIComponent(name)}`, {
// Get current directory for project-level agent support
const currentDirectory = getCurrentDirectory();
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
const response = await fetch(`/api/config/agents/${encodeURIComponent(name)}${queryParams}`, {
method: 'DELETE'
});
+12 -6
View File
@@ -2,7 +2,7 @@ import * as vscode from 'vscode';
import * as os from 'os';
import * as path from 'path';
import type { OpenCodeManager } from './opencode';
import { createAgent, createCommand, deleteAgent, deleteCommand, getAgentSources, getCommandSources, updateAgent, updateCommand, type CommandScope, COMMAND_SCOPE } from './opencodeConfig';
import { createAgent, createCommand, deleteAgent, deleteCommand, getAgentSources, getCommandSources, updateAgent, updateCommand, type AgentScope, type CommandScope, AGENT_SCOPE, COMMAND_SCOPE } from './opencodeConfig';
import { removeProviderAuth } from './opencodeAuth';
export interface BridgeRequest {
@@ -617,19 +617,25 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
return { id, type, success: false, error: 'Agent name is required' };
}
// Get working directory for project-level agent support
const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET';
if (normalizedMethod === 'GET') {
const sources = getAgentSources(agentName);
const sources = getAgentSources(agentName, workingDirectory);
return {
id,
type,
success: true,
data: { name: agentName, sources, isBuiltIn: !sources.md.exists && !sources.json.exists },
data: { name: agentName, sources, scope: sources.md.scope, isBuiltIn: !sources.md.exists && !sources.json.exists },
};
}
if (normalizedMethod === 'POST') {
createAgent(agentName, (body || {}) as Record<string, unknown>);
// Extract scope from body if present
const scopeValue = body?.scope as string | undefined;
const scope: AgentScope | undefined = scopeValue === 'project' ? AGENT_SCOPE.PROJECT : scopeValue === 'user' ? AGENT_SCOPE.USER : undefined;
createAgent(agentName, (body || {}) as Record<string, unknown>, workingDirectory, scope);
await ctx?.manager?.restart();
return {
id,
@@ -645,7 +651,7 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
}
if (normalizedMethod === 'PATCH') {
updateAgent(agentName, (body || {}) as Record<string, unknown>);
updateAgent(agentName, (body || {}) as Record<string, unknown>, workingDirectory);
await ctx?.manager?.restart();
return {
id,
@@ -661,7 +667,7 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
}
if (normalizedMethod === 'DELETE') {
deleteAgent(agentName);
deleteAgent(agentName, workingDirectory);
await ctx?.manager?.restart();
return {
id,
+365 -100
View File
@@ -8,19 +8,28 @@ const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode');
const AGENT_DIR = path.join(OPENCODE_CONFIG_DIR, 'agent');
const COMMAND_DIR = path.join(OPENCODE_CONFIG_DIR, 'command');
const CONFIG_FILE = path.join(OPENCODE_CONFIG_DIR, 'opencode.json');
const CUSTOM_CONFIG_FILE = process.env.OPENCODE_CONFIG
? path.resolve(process.env.OPENCODE_CONFIG)
: null;
const PROMPT_FILE_PATTERN = /^\{file:(.+)\}$/i;
// Command scope types
// Scope types (shared by agents and commands)
export const AGENT_SCOPE = {
USER: 'user',
PROJECT: 'project'
} as const;
export const COMMAND_SCOPE = {
USER: 'user',
PROJECT: 'project'
} as const;
export type AgentScope = typeof AGENT_SCOPE[keyof typeof AGENT_SCOPE];
export type CommandScope = typeof COMMAND_SCOPE[keyof typeof COMMAND_SCOPE];
export type ConfigSources = {
md: { exists: boolean; path: string | null; fields: string[]; scope?: CommandScope | null };
json: { exists: boolean; path: string; fields: string[] };
md: { exists: boolean; path: string | null; fields: string[]; scope?: AgentScope | CommandScope | null };
json: { exists: boolean; path: string; fields: string[]; scope?: AgentScope | CommandScope | null };
projectMd?: { exists: boolean; path: string | null };
userMd?: { exists: boolean; path: string | null };
};
@@ -31,6 +40,62 @@ const ensureDirs = () => {
if (!fs.existsSync(COMMAND_DIR)) fs.mkdirSync(COMMAND_DIR, { recursive: true });
};
// ============== AGENT SCOPE HELPERS ==============
const ensureProjectAgentDir = (workingDirectory: string): string => {
const projectAgentDir = path.join(workingDirectory, '.opencode', 'agent');
if (!fs.existsSync(projectAgentDir)) {
fs.mkdirSync(projectAgentDir, { recursive: true });
}
return projectAgentDir;
};
const getProjectAgentPath = (workingDirectory: string, agentName: string): string => {
return path.join(workingDirectory, '.opencode', 'agent', `${agentName}.md`);
};
const getUserAgentPath = (agentName: string): string => {
return path.join(AGENT_DIR, `${agentName}.md`);
};
export const getAgentScope = (agentName: string, workingDirectory?: string): { scope: AgentScope | null; path: string | null } => {
if (workingDirectory) {
const projectPath = getProjectAgentPath(workingDirectory, agentName);
if (fs.existsSync(projectPath)) {
return { scope: AGENT_SCOPE.PROJECT, path: projectPath };
}
}
const userPath = getUserAgentPath(agentName);
if (fs.existsSync(userPath)) {
return { scope: AGENT_SCOPE.USER, path: userPath };
}
return { scope: null, path: null };
};
const getAgentWritePath = (agentName: string, workingDirectory?: string, requestedScope?: AgentScope): { scope: AgentScope; path: string } => {
const existing = getAgentScope(agentName, workingDirectory);
if (existing.path) {
return { scope: existing.scope!, path: existing.path };
}
const scope = requestedScope || AGENT_SCOPE.USER;
if (scope === AGENT_SCOPE.PROJECT && workingDirectory) {
return {
scope: AGENT_SCOPE.PROJECT,
path: getProjectAgentPath(workingDirectory, agentName)
};
}
return {
scope: AGENT_SCOPE.USER,
path: getUserAgentPath(agentName)
};
};
// ============== COMMAND SCOPE HELPERS ==============
const ensureProjectCommandDir = (workingDirectory: string): string => {
const projectCommandDir = path.join(workingDirectory, '.opencode', 'command');
if (!fs.existsSync(projectCommandDir)) {
@@ -107,24 +172,116 @@ const writePromptFile = (filePath: string, content: string) => {
fs.writeFileSync(filePath, content, 'utf8');
};
const readConfig = (): Record<string, unknown> => {
if (!fs.existsSync(CONFIG_FILE)) return {};
const content = fs.readFileSync(CONFIG_FILE, 'utf8');
const getProjectConfigPath = (workingDirectory?: string): string | null => {
if (!workingDirectory) return null;
return path.join(workingDirectory, 'opencode.json');
};
const getConfigPaths = (workingDirectory?: string) => ({
userPath: CONFIG_FILE,
projectPath: getProjectConfigPath(workingDirectory),
customPath: CUSTOM_CONFIG_FILE
});
const readConfigFile = (filePath?: string | null): Record<string, unknown> => {
if (!filePath || !fs.existsSync(filePath)) return {};
const content = fs.readFileSync(filePath, 'utf8');
const normalized = stripJsonComments(content).trim();
if (!normalized) return {};
return JSON.parse(normalized) as Record<string, unknown>;
};
const writeConfig = (config: Record<string, unknown>) => {
if (fs.existsSync(CONFIG_FILE)) {
const backupFile = `${CONFIG_FILE}.openchamber.backup`;
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const mergeConfigs = (base: Record<string, unknown>, override: Record<string, unknown>): Record<string, unknown> => {
const result: Record<string, unknown> = { ...base };
for (const [key, value] of Object.entries(override)) {
if (key in result) {
const baseValue = result[key];
if (isPlainObject(baseValue) && isPlainObject(value)) {
result[key] = mergeConfigs(baseValue, value);
} else {
result[key] = value;
}
} else {
result[key] = value;
}
}
return result;
};
const readConfigLayers = (workingDirectory?: string) => {
const { userPath, projectPath, customPath } = getConfigPaths(workingDirectory);
const userConfig = readConfigFile(userPath);
const projectConfig = readConfigFile(projectPath);
const customConfig = readConfigFile(customPath);
const mergedConfig = mergeConfigs(mergeConfigs(userConfig, projectConfig), customConfig);
return {
userConfig,
projectConfig,
customConfig,
mergedConfig,
paths: { userPath, projectPath, customPath }
};
};
const readConfig = (workingDirectory?: string): Record<string, unknown> =>
readConfigLayers(workingDirectory).mergedConfig;
const writeConfig = (config: Record<string, unknown>, filePath: string = CONFIG_FILE) => {
if (fs.existsSync(filePath)) {
const backupFile = `${filePath}.openchamber.backup`;
try {
fs.copyFileSync(CONFIG_FILE, backupFile);
fs.copyFileSync(filePath, backupFile);
} catch {
// ignore backup failures
}
}
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(config, null, 2), 'utf8');
};
const getJsonEntrySource = (
layers: ReturnType<typeof readConfigLayers>,
sectionKey: 'agent' | 'command',
entryName: string
) => {
const { userConfig, projectConfig, customConfig, paths } = layers;
const customSection = (customConfig as Record<string, unknown>)?.[sectionKey] as Record<string, unknown> | undefined;
if (customSection?.[entryName] !== undefined) {
return { section: customSection[entryName], config: customConfig, path: paths.customPath, exists: true };
}
const projectSection = (projectConfig as Record<string, unknown>)?.[sectionKey] as Record<string, unknown> | undefined;
if (projectSection?.[entryName] !== undefined) {
return { section: projectSection[entryName], config: projectConfig, path: paths.projectPath, exists: true };
}
const userSection = (userConfig as Record<string, unknown>)?.[sectionKey] as Record<string, unknown> | undefined;
if (userSection?.[entryName] !== undefined) {
return { section: userSection[entryName], config: userConfig, path: paths.userPath, exists: true };
}
return { section: null, config: null, path: null, exists: false };
};
const getJsonWriteTarget = (
layers: ReturnType<typeof readConfigLayers>,
preferredScope: AgentScope | CommandScope
) => {
const { userConfig, projectConfig, customConfig, paths } = layers;
if (paths.customPath) {
return { config: customConfig, path: paths.customPath };
}
if (preferredScope === AGENT_SCOPE.PROJECT && paths.projectPath) {
return { config: projectConfig, path: paths.projectPath };
}
if (paths.projectPath) {
return { config: projectConfig, path: paths.projectPath };
}
return { config: userConfig, path: paths.userPath };
};
const parseMdFile = (filePath: string): { frontmatter: Record<string, unknown>; body: string } => {
@@ -144,19 +301,34 @@ const writeMdFile = (filePath: string, frontmatter: Record<string, unknown>, bod
fs.writeFileSync(filePath, content, 'utf8');
};
export const getAgentSources = (agentName: string): ConfigSources => {
const mdPath = path.join(AGENT_DIR, `${agentName}.md`);
const mdExists = fs.existsSync(mdPath);
export const getAgentSources = (agentName: string, workingDirectory?: string): ConfigSources => {
// Check project level first (takes precedence)
const projectPath = workingDirectory ? getProjectAgentPath(workingDirectory, agentName) : null;
const projectExists = projectPath ? fs.existsSync(projectPath) : false;
// Then check user level
const userPath = getUserAgentPath(agentName);
const userExists = fs.existsSync(userPath);
// Determine which md file to use (project takes precedence)
const mdPath = projectExists ? projectPath : (userExists ? userPath : null);
const mdExists = !!mdPath;
const mdScope = projectExists ? AGENT_SCOPE.PROJECT : (userExists ? AGENT_SCOPE.USER : null);
const config = readConfig();
const agentSection = (config.agent as Record<string, unknown> | undefined)?.[agentName] as Record<string, unknown> | undefined;
const layers = readConfigLayers(workingDirectory);
const jsonSource = getJsonEntrySource(layers, 'agent', agentName);
const agentSection = jsonSource.section as Record<string, unknown> | undefined;
const jsonPath = jsonSource.path || layers.paths.customPath || layers.paths.projectPath || layers.paths.userPath;
const jsonScope = jsonSource.path === layers.paths.projectPath ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER;
const sources: ConfigSources = {
md: { exists: mdExists, path: mdExists ? mdPath : null, fields: [] },
json: { exists: Boolean(agentSection), path: CONFIG_FILE, fields: [] },
md: { exists: mdExists, path: mdPath, scope: mdScope, fields: [] },
json: { exists: jsonSource.exists, path: jsonPath || CONFIG_FILE, scope: jsonSource.exists ? jsonScope : null, fields: [] },
projectMd: { exists: projectExists, path: projectPath },
userMd: { exists: userExists, path: userPath }
};
if (mdExists) {
if (mdExists && mdPath) {
const { frontmatter, body } = parseMdFile(mdPath);
sources.md.fields = Object.keys(frontmatter);
if (body) sources.md.fields.push('prompt');
@@ -169,41 +341,86 @@ export const getAgentSources = (agentName: string): ConfigSources => {
return sources;
};
export const createAgent = (agentName: string, config: Record<string, unknown>) => {
export const createAgent = (agentName: string, config: Record<string, unknown>, workingDirectory?: string, scope?: AgentScope) => {
ensureDirs();
const mdPath = path.join(AGENT_DIR, `${agentName}.md`);
if (fs.existsSync(mdPath)) throw new Error(`Agent ${agentName} already exists as .md file`);
// Check if agent already exists at either level
const projectPath = workingDirectory ? getProjectAgentPath(workingDirectory, agentName) : null;
const userPath = getUserAgentPath(agentName);
if (projectPath && fs.existsSync(projectPath)) {
throw new Error(`Agent ${agentName} already exists as project-level .md file`);
}
if (fs.existsSync(userPath)) {
throw new Error(`Agent ${agentName} already exists as user-level .md file`);
}
const existingConfig = readConfig();
const agentMap = existingConfig.agent as Record<string, unknown> | undefined;
if (agentMap?.[agentName]) throw new Error(`Agent ${agentName} already exists in opencode.json`);
const layers = readConfigLayers(workingDirectory);
const jsonSource = getJsonEntrySource(layers, 'agent', agentName);
if (jsonSource.exists) throw new Error(`Agent ${agentName} already exists in opencode.json`);
const { prompt, ...frontmatter } = config as Record<string, unknown> & { prompt?: unknown };
writeMdFile(mdPath, frontmatter, typeof prompt === 'string' ? prompt : '');
// Determine target path based on requested scope
let targetPath: string;
if (scope === AGENT_SCOPE.PROJECT && workingDirectory) {
ensureProjectAgentDir(workingDirectory);
targetPath = projectPath!;
} else {
targetPath = userPath;
}
// Extract scope and prompt from config - scope is only used for path determination, not written to file
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 : '');
};
export const updateAgent = (agentName: string, updates: Record<string, unknown>) => {
export const updateAgent = (agentName: string, updates: Record<string, unknown>, workingDirectory?: string) => {
ensureDirs();
const mdPath = path.join(AGENT_DIR, `${agentName}.md`);
const mdExists = fs.existsSync(mdPath);
// Determine correct path: project level takes precedence
const { path: mdPath } = getAgentWritePath(agentName, workingDirectory);
const mdExists = mdPath ? fs.existsSync(mdPath) : false;
// Check if agent exists in opencode.json across all config layers
const layers = readConfigLayers(workingDirectory);
const jsonSource = getJsonEntrySource(layers, 'agent', agentName);
const jsonSection = jsonSource.section as Record<string, unknown> | undefined;
const hasJsonFields = Boolean(jsonSource.exists && jsonSection && Object.keys(jsonSection).length > 0);
const jsonTarget = jsonSource.exists
? { config: jsonSource.config, path: jsonSource.path }
: getJsonWriteTarget(layers, workingDirectory ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER);
const config = (jsonTarget.config || {}) as Record<string, unknown>;
// Determine if we should create a new md file:
// Only for built-in agents (no md file AND no json config)
const isBuiltinOverride = !mdExists && !hasJsonFields;
let targetPath = mdPath;
if (!mdExists && isBuiltinOverride) {
// Built-in agent override - create at user level
targetPath = getUserAgentPath(agentName);
}
const mdData = mdExists ? parseMdFile(mdPath) : null;
const config = readConfig();
const agentMap = (config.agent as Record<string, unknown> | undefined) ?? {};
const jsonSection = agentMap[agentName] as Record<string, unknown> | undefined;
// Only create md data for existing md files or built-in overrides
const mdData = mdExists && mdPath ? parseMdFile(mdPath) : (isBuiltinOverride ? { frontmatter: {} as Record<string, unknown>, body: '' } : null);
let mdModified = false;
let jsonModified = false;
// Only create new md if it's a built-in override
let creatingNewMd = isBuiltinOverride;
for (const [field, value] of Object.entries(updates || {})) {
if (field === 'prompt') {
const normalizedValue = typeof value === 'string' ? value : value == null ? '' : String(value);
if (mdExists && mdData) {
mdData.body = normalizedValue;
mdModified = true;
if (mdExists || creatingNewMd) {
if (mdData) {
mdData.body = normalizedValue;
mdModified = true;
}
continue;
}
@@ -214,9 +431,10 @@ export const updateAgent = (agentName: string, updates: Record<string, unknown>)
continue;
}
// For JSON-only agents, store prompt inline in JSON
if (!config.agent) config.agent = {};
const target = (config.agent as Record<string, unknown>)[agentName] as Record<string, unknown> | undefined;
(config.agent as Record<string, unknown>)[agentName] = { ...(target || {}), prompt: normalizedValue };
const current = ((config.agent as Record<string, unknown>)[agentName] as Record<string, unknown> | undefined) ?? {};
(config.agent as Record<string, unknown>)[agentName] = { ...current, prompt: normalizedValue };
jsonModified = true;
continue;
}
@@ -224,53 +442,83 @@ export const updateAgent = (agentName: string, updates: Record<string, unknown>)
const hasMdField = Boolean(mdData?.frontmatter?.[field] !== undefined);
const hasJsonField = Boolean(jsonSection?.[field] !== undefined);
if (hasMdField && mdData) {
mdData.frontmatter[field] = value;
mdModified = true;
// JSON takes precedence over md, so update JSON first if field exists there
if (hasJsonField) {
if (!config.agent) config.agent = {};
const current = ((config.agent as Record<string, unknown>)[agentName] as Record<string, unknown> | undefined) ?? {};
(config.agent as Record<string, unknown>)[agentName] = { ...current, [field]: value };
jsonModified = true;
continue;
}
if (!config.agent) config.agent = {};
const current = ((config.agent as Record<string, unknown>)[agentName] as Record<string, unknown> | undefined) ?? {};
(config.agent as Record<string, unknown>)[agentName] = { ...current, [field]: value };
jsonModified = true;
if (hasJsonField) {
if (hasMdField || creatingNewMd) {
if (mdData) {
mdData.frontmatter[field] = value;
mdModified = true;
}
continue;
}
// New field - add to appropriate location based on agent source
if ((mdExists || creatingNewMd) && mdData) {
mdData.frontmatter[field] = value;
mdModified = true;
} else {
if (!config.agent) config.agent = {};
const current = ((config.agent as Record<string, unknown>)[agentName] as Record<string, unknown> | undefined) ?? {};
(config.agent as Record<string, unknown>)[agentName] = { ...current, [field]: value };
jsonModified = true;
}
}
if (mdModified && mdData) {
writeMdFile(mdPath, mdData.frontmatter, mdData.body);
if (mdModified && mdData && targetPath) {
writeMdFile(targetPath, mdData.frontmatter, mdData.body);
}
if (jsonModified) {
writeConfig(config);
writeConfig(config, jsonTarget.path || CONFIG_FILE);
}
};
export const deleteAgent = (agentName: string) => {
const mdPath = path.join(AGENT_DIR, `${agentName}.md`);
export const deleteAgent = (agentName: string, workingDirectory?: string) => {
let deleted = false;
if (fs.existsSync(mdPath)) {
fs.unlinkSync(mdPath);
// Check project level first (takes precedence)
if (workingDirectory) {
const projectPath = getProjectAgentPath(workingDirectory, agentName);
if (fs.existsSync(projectPath)) {
fs.unlinkSync(projectPath);
deleted = true;
}
}
// Then check user level
const userPath = getUserAgentPath(agentName);
if (fs.existsSync(userPath)) {
fs.unlinkSync(userPath);
deleted = true;
}
const config = readConfig();
const agentMap = (config.agent as Record<string, unknown> | undefined) ?? {};
if (agentMap[agentName] !== undefined) {
// Also check json config (highest precedence entry only)
const layers = readConfigLayers(workingDirectory);
const jsonSource = getJsonEntrySource(layers, 'agent', agentName);
if (jsonSource.exists && jsonSource.config && jsonSource.path) {
const targetConfig = jsonSource.config as Record<string, unknown>;
const agentMap = (targetConfig.agent as Record<string, unknown> | undefined) ?? {};
delete agentMap[agentName];
config.agent = agentMap;
writeConfig(config);
targetConfig.agent = agentMap;
writeConfig(targetConfig, jsonSource.path);
deleted = true;
}
// If nothing was deleted (built-in agent), disable it in highest-precedence config
if (!deleted) {
config.agent = agentMap;
const jsonTarget = getJsonWriteTarget(layers, workingDirectory ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER);
const targetConfig = (jsonTarget.config || {}) as Record<string, unknown>;
const agentMap = (targetConfig.agent as Record<string, unknown> | undefined) ?? {};
agentMap[agentName] = { disable: true };
writeConfig(config);
targetConfig.agent = agentMap;
writeConfig(targetConfig, jsonTarget.path || CONFIG_FILE);
}
};
@@ -288,12 +536,14 @@ export const getCommandSources = (commandName: string, workingDirectory?: string
const mdExists = !!mdPath;
const mdScope = projectExists ? COMMAND_SCOPE.PROJECT : (userExists ? COMMAND_SCOPE.USER : null);
const config = readConfig();
const commandSection = (config.command as Record<string, unknown> | undefined)?.[commandName] as Record<string, unknown> | undefined;
const layers = readConfigLayers(workingDirectory);
const jsonSource = getJsonEntrySource(layers, 'command', commandName);
const commandSection = jsonSource.section as Record<string, unknown> | undefined;
const jsonPath = jsonSource.path || layers.paths.customPath || layers.paths.projectPath || layers.paths.userPath;
const sources: ConfigSources = {
md: { exists: mdExists, path: mdPath, scope: mdScope, fields: [] },
json: { exists: Boolean(commandSection), path: CONFIG_FILE, fields: [] },
json: { exists: jsonSource.exists, path: jsonPath || CONFIG_FILE, fields: [] },
projectMd: { exists: projectExists, path: projectPath },
userMd: { exists: userExists, path: userPath }
};
@@ -326,9 +576,9 @@ export const createCommand = (commandName: string, config: Record<string, unknow
throw new Error(`Command ${commandName} already exists as user-level .md file`);
}
const existingConfig = readConfig();
const commandMap = existingConfig.command as Record<string, unknown> | undefined;
if (commandMap?.[commandName]) throw new Error(`Command ${commandName} already exists in opencode.json`);
const layers = readConfigLayers(workingDirectory);
const jsonSource = getJsonEntrySource(layers, 'command', commandName);
if (jsonSource.exists) throw new Error(`Command ${commandName} already exists in opencode.json`);
// Determine target path based on requested scope
let targetPath: string;
@@ -352,31 +602,40 @@ export const updateCommand = (commandName: string, updates: Record<string, unkno
// Determine correct path: project level takes precedence
const { path: mdPath } = getCommandWritePath(commandName, workingDirectory);
const mdExists = mdPath ? fs.existsSync(mdPath) : false;
// If no existing md file, we need to create one (for built-in command overrides)
const layers = readConfigLayers(workingDirectory);
const jsonSource = getJsonEntrySource(layers, 'command', commandName);
const jsonSection = jsonSource.section as Record<string, unknown> | undefined;
const hasJsonFields = Boolean(jsonSource.exists && jsonSection && Object.keys(jsonSection).length > 0);
const jsonTarget = jsonSource.exists
? { config: jsonSource.config, path: jsonSource.path }
: getJsonWriteTarget(layers, workingDirectory ? COMMAND_SCOPE.PROJECT : COMMAND_SCOPE.USER);
const config = (jsonTarget.config || {}) as Record<string, unknown>;
// Only create a new md file for built-in overrides (no md + no json)
const isBuiltinOverride = !mdExists && !hasJsonFields;
let targetPath = mdPath;
if (!mdExists) {
// No existing md file - this is a built-in override, create at user level
if (!mdExists && isBuiltinOverride) {
// Built-in command override - create at user level
targetPath = getUserCommandPath(commandName);
}
const mdData = mdExists && mdPath ? parseMdFile(mdPath) : { frontmatter: {} as Record<string, unknown>, body: '' };
const config = readConfig();
const commandMap = (config.command as Record<string, unknown> | undefined) ?? {};
const jsonSection = commandMap[commandName] as Record<string, unknown> | undefined;
const mdData = mdExists && mdPath ? parseMdFile(mdPath) : (isBuiltinOverride ? { frontmatter: {} as Record<string, unknown>, body: '' } : null);
let mdModified = false;
let jsonModified = false;
let creatingNewMd = !mdExists;
let creatingNewMd = isBuiltinOverride;
for (const [field, value] of Object.entries(updates || {})) {
if (field === 'template') {
const normalizedValue = typeof value === 'string' ? value : value == null ? '' : String(value);
if (mdExists || creatingNewMd) {
mdData.body = normalizedValue;
mdModified = true;
if (mdData) {
mdData.body = normalizedValue;
mdModified = true;
}
continue;
}
@@ -387,22 +646,18 @@ export const updateCommand = (commandName: string, updates: Record<string, unkno
continue;
}
// Create new md file for the update
mdData.body = normalizedValue;
mdModified = true;
creatingNewMd = true;
// For JSON-only commands, store template inline in JSON
if (!config.command) config.command = {};
const current = ((config.command as Record<string, unknown>)[commandName] as Record<string, unknown> | undefined) ?? {};
(config.command as Record<string, unknown>)[commandName] = { ...current, template: normalizedValue };
jsonModified = true;
continue;
}
const hasMdField = Boolean(mdData?.frontmatter?.[field] !== undefined);
const hasJsonField = Boolean(jsonSection?.[field] !== undefined);
if (hasMdField || creatingNewMd) {
mdData.frontmatter[field] = value;
mdModified = true;
continue;
}
// JSON takes precedence over md, so update JSON first if field exists there
if (hasJsonField) {
if (!config.command) config.command = {};
const current = ((config.command as Record<string, unknown>)[commandName] as Record<string, unknown> | undefined) ?? {};
@@ -411,8 +666,16 @@ export const updateCommand = (commandName: string, updates: Record<string, unkno
continue;
}
// New field - add to md if it exists or we're creating one
if (mdExists || creatingNewMd) {
if (hasMdField || creatingNewMd) {
if (mdData) {
mdData.frontmatter[field] = value;
mdModified = true;
}
continue;
}
// New field - add to appropriate location based on command source
if ((mdExists || creatingNewMd) && mdData) {
mdData.frontmatter[field] = value;
mdModified = true;
} else {
@@ -423,12 +686,12 @@ export const updateCommand = (commandName: string, updates: Record<string, unkno
}
}
if (mdModified && targetPath) {
if (mdModified && mdData && targetPath) {
writeMdFile(targetPath, mdData.frontmatter, mdData.body);
}
if (jsonModified) {
writeConfig(config);
writeConfig(config, jsonTarget.path || CONFIG_FILE);
}
};
@@ -451,13 +714,15 @@ export const deleteCommand = (commandName: string, workingDirectory?: string) =>
deleted = true;
}
// Also check json config
const config = readConfig();
const commandMap = (config.command as Record<string, unknown> | undefined) ?? {};
if (commandMap[commandName] !== undefined) {
// Also check json config (highest precedence entry only)
const layers = readConfigLayers(workingDirectory);
const jsonSource = getJsonEntrySource(layers, 'command', commandName);
if (jsonSource.exists && jsonSource.config && jsonSource.path) {
const targetConfig = jsonSource.config as Record<string, unknown>;
const commandMap = (targetConfig.command as Record<string, unknown> | undefined) ?? {};
delete commandMap[commandName];
config.command = commandMap;
writeConfig(config);
targetConfig.command = commandMap;
writeConfig(targetConfig, jsonSource.path);
deleted = true;
}
+17 -5
View File
@@ -2165,6 +2165,7 @@ async function main(options = {}) {
const {
getAgentSources,
getAgentScope,
createAgent,
updateAgent,
deleteAgent,
@@ -2173,17 +2174,20 @@ async function main(options = {}) {
createCommand,
updateCommand,
deleteCommand,
AGENT_SCOPE,
COMMAND_SCOPE
} = await import('./lib/opencode-config.js');
app.get('/api/config/agents/:name', (req, res) => {
try {
const agentName = req.params.name;
const sources = getAgentSources(agentName);
const workingDirectory = req.query.directory || openCodeWorkingDirectory;
const sources = getAgentSources(agentName, workingDirectory);
res.json({
name: agentName,
sources: sources,
scope: sources.md.scope,
isBuiltIn: !sources.md.exists && !sources.json.exists
});
} catch (error) {
@@ -2195,9 +2199,14 @@ async function main(options = {}) {
app.post('/api/config/agents/:name', async (req, res) => {
try {
const agentName = req.params.name;
const config = req.body;
const { scope, ...config } = req.body;
const workingDirectory = req.query.directory || openCodeWorkingDirectory;
createAgent(agentName, config);
console.log('[Server] Creating agent:', agentName);
console.log('[Server] Config received:', JSON.stringify(config, null, 2));
console.log('[Server] Scope:', scope, 'Working directory:', workingDirectory);
createAgent(agentName, config, workingDirectory, scope);
await refreshOpenCodeAfterConfigChange('agent creation', {
agentName
});
@@ -2218,11 +2227,13 @@ async function main(options = {}) {
try {
const agentName = req.params.name;
const updates = req.body;
const workingDirectory = req.query.directory || openCodeWorkingDirectory;
console.log(`[Server] Updating agent: ${agentName}`);
console.log('[Server] Updates:', JSON.stringify(updates, null, 2));
console.log('[Server] Working directory:', workingDirectory);
updateAgent(agentName, updates);
updateAgent(agentName, updates, workingDirectory);
await refreshOpenCodeAfterConfigChange('agent update');
console.log(`[Server] Agent ${agentName} updated successfully`);
@@ -2243,8 +2254,9 @@ async function main(options = {}) {
app.delete('/api/config/agents/:name', async (req, res) => {
try {
const agentName = req.params.name;
const workingDirectory = req.query.directory || openCodeWorkingDirectory;
deleteAgent(agentName);
deleteAgent(agentName, workingDirectory);
await refreshOpenCodeAfterConfigChange('agent deletion');
res.json({
+403 -113
View File
@@ -8,9 +8,17 @@ const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode');
const AGENT_DIR = path.join(OPENCODE_CONFIG_DIR, 'agent');
const COMMAND_DIR = path.join(OPENCODE_CONFIG_DIR, 'command');
const CONFIG_FILE = path.join(OPENCODE_CONFIG_DIR, 'opencode.json');
const CUSTOM_CONFIG_FILE = process.env.OPENCODE_CONFIG
? path.resolve(process.env.OPENCODE_CONFIG)
: null;
const PROMPT_FILE_PATTERN = /^\{file:(.+)\}$/i;
// Command scope types
// Scope types (shared by agents and commands)
const AGENT_SCOPE = {
USER: 'user',
PROJECT: 'project'
};
const COMMAND_SCOPE = {
USER: 'user',
PROJECT: 'project'
@@ -28,6 +36,80 @@ function ensureDirs() {
}
}
// ============== AGENT SCOPE HELPERS ==============
/**
* Ensure project-level agent directory exists
*/
function ensureProjectAgentDir(workingDirectory) {
const projectAgentDir = path.join(workingDirectory, '.opencode', 'agent');
if (!fs.existsSync(projectAgentDir)) {
fs.mkdirSync(projectAgentDir, { recursive: true });
}
return projectAgentDir;
}
/**
* Get project-level agent path
*/
function getProjectAgentPath(workingDirectory, agentName) {
return path.join(workingDirectory, '.opencode', 'agent', `${agentName}.md`);
}
/**
* Get user-level agent path
*/
function getUserAgentPath(agentName) {
return path.join(AGENT_DIR, `${agentName}.md`);
}
/**
* Determine agent scope based on where the .md file exists
* Priority: project level > user level > null (built-in only)
*/
function getAgentScope(agentName, workingDirectory) {
if (workingDirectory) {
const projectPath = getProjectAgentPath(workingDirectory, agentName);
if (fs.existsSync(projectPath)) {
return { scope: AGENT_SCOPE.PROJECT, path: projectPath };
}
}
const userPath = getUserAgentPath(agentName);
if (fs.existsSync(userPath)) {
return { scope: AGENT_SCOPE.USER, path: userPath };
}
return { scope: null, path: null };
}
/**
* Get the path where an agent should be written based on scope
*/
function getAgentWritePath(agentName, workingDirectory, requestedScope) {
// For updates: check existing location first (project takes precedence)
const existing = getAgentScope(agentName, workingDirectory);
if (existing.path) {
return existing;
}
// For new agents or built-in overrides: use requested scope or default to user
const scope = requestedScope || AGENT_SCOPE.USER;
if (scope === AGENT_SCOPE.PROJECT && workingDirectory) {
return {
scope: AGENT_SCOPE.PROJECT,
path: getProjectAgentPath(workingDirectory, agentName)
};
}
return {
scope: AGENT_SCOPE.USER,
path: getUserAgentPath(agentName)
};
}
// ============== COMMAND SCOPE HELPERS ==============
/**
* Ensure project-level command directory exists
*/
@@ -132,40 +214,131 @@ function writePromptFile(filePath, content) {
console.log(`Updated prompt file: ${filePath}`);
}
function readConfig() {
if (!fs.existsSync(CONFIG_FILE)) {
function getProjectConfigPath(workingDirectory) {
if (!workingDirectory) return null;
return path.join(workingDirectory, 'opencode.json');
}
function getConfigPaths(workingDirectory) {
return {
userPath: CONFIG_FILE,
projectPath: getProjectConfigPath(workingDirectory),
customPath: CUSTOM_CONFIG_FILE
};
}
function readConfigFile(filePath) {
if (!filePath || !fs.existsSync(filePath)) {
return {};
}
try {
const content = fs.readFileSync(CONFIG_FILE, 'utf8');
const content = fs.readFileSync(filePath, 'utf8');
const normalized = stripJsonComments(content).trim();
if (!normalized) {
return {};
}
return JSON.parse(normalized);
} catch (error) {
console.error('Failed to read config file:', error);
console.error(`Failed to read config file: ${filePath}`, error);
throw new Error('Failed to read OpenCode configuration');
}
}
function writeConfig(config) {
try {
function isPlainObject(value) {
return value && typeof value === 'object' && !Array.isArray(value);
}
if (fs.existsSync(CONFIG_FILE)) {
const backupFile = `${CONFIG_FILE}.openchamber.backup`;
fs.copyFileSync(CONFIG_FILE, backupFile);
function mergeConfigs(base, override) {
if (!isPlainObject(base) || !isPlainObject(override)) {
return override;
}
const result = { ...base };
for (const [key, value] of Object.entries(override)) {
if (key in result) {
const baseValue = result[key];
if (isPlainObject(baseValue) && isPlainObject(value)) {
result[key] = mergeConfigs(baseValue, value);
} else {
result[key] = value;
}
} else {
result[key] = value;
}
}
return result;
}
function readConfigLayers(workingDirectory) {
const { userPath, projectPath, customPath } = getConfigPaths(workingDirectory);
const userConfig = readConfigFile(userPath);
const projectConfig = readConfigFile(projectPath);
const customConfig = readConfigFile(customPath);
const mergedConfig = mergeConfigs(mergeConfigs(userConfig, projectConfig), customConfig);
return {
userConfig,
projectConfig,
customConfig,
mergedConfig,
paths: { userPath, projectPath, customPath }
};
}
function readConfig(workingDirectory) {
return readConfigLayers(workingDirectory).mergedConfig;
}
function writeConfig(config, filePath = CONFIG_FILE) {
try {
if (fs.existsSync(filePath)) {
const backupFile = `${filePath}.openchamber.backup`;
fs.copyFileSync(filePath, backupFile);
console.log(`Created config backup: ${backupFile}`);
}
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
console.log('Successfully wrote config file');
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(config, null, 2), 'utf8');
console.log(`Successfully wrote config file: ${filePath}`);
} catch (error) {
console.error('Failed to write config file:', error);
console.error(`Failed to write config file: ${filePath}`, error);
throw new Error('Failed to write OpenCode configuration');
}
}
function getJsonEntrySource(layers, sectionKey, entryName) {
const { userConfig, projectConfig, customConfig, paths } = layers;
const customSection = customConfig?.[sectionKey]?.[entryName];
if (customSection !== undefined) {
return { section: customSection, config: customConfig, path: paths.customPath, exists: true };
}
const projectSection = projectConfig?.[sectionKey]?.[entryName];
if (projectSection !== undefined) {
return { section: projectSection, config: projectConfig, path: paths.projectPath, exists: true };
}
const userSection = userConfig?.[sectionKey]?.[entryName];
if (userSection !== undefined) {
return { section: userSection, config: userConfig, path: paths.userPath, exists: true };
}
return { section: null, config: null, path: null, exists: false };
}
function getJsonWriteTarget(layers, preferredScope) {
const { userConfig, projectConfig, customConfig, paths } = layers;
if (paths.customPath) {
return { config: customConfig, path: paths.customPath };
}
if (preferredScope === AGENT_SCOPE.PROJECT && paths.projectPath) {
return { config: projectConfig, path: paths.projectPath };
}
if (paths.projectPath) {
return { config: projectConfig, path: paths.projectPath };
}
return { config: userConfig, path: paths.userPath };
}
function parseMdFile(filePath) {
try {
const content = fs.readFileSync(filePath, 'utf8');
@@ -201,23 +374,53 @@ function writeMdFile(filePath, frontmatter, body) {
}
}
function getAgentSources(agentName) {
const mdPath = path.join(AGENT_DIR, `${agentName}.md`);
const mdExists = fs.existsSync(mdPath);
function getAgentSources(agentName, workingDirectory) {
// Check project level first (takes precedence)
const projectPath = workingDirectory ? getProjectAgentPath(workingDirectory, agentName) : null;
const projectExists = projectPath && fs.existsSync(projectPath);
// Then check user level
const userPath = getUserAgentPath(agentName);
const userExists = fs.existsSync(userPath);
// Determine which md file to use (project takes precedence)
const mdPath = projectExists ? projectPath : (userExists ? userPath : null);
const mdExists = !!mdPath;
const mdScope = projectExists ? AGENT_SCOPE.PROJECT : (userExists ? AGENT_SCOPE.USER : null);
const config = readConfig();
const jsonSection = config.agent?.[agentName];
const layers = readConfigLayers(workingDirectory);
const jsonSource = getJsonEntrySource(layers, 'agent', agentName);
const jsonSection = jsonSource.section;
const jsonPath = jsonSource.path || layers.paths.customPath || layers.paths.projectPath || layers.paths.userPath;
const jsonScope = jsonSource.path === layers.paths.projectPath ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER;
const sources = {
md: {
exists: mdExists,
path: mdExists ? mdPath : null,
path: mdPath,
scope: mdScope,
fields: []
},
json: {
exists: !!jsonSection,
path: CONFIG_FILE,
exists: jsonSource.exists,
path: jsonPath,
scope: jsonSource.exists ? jsonScope : null,
fields: []
},
json: {
exists: jsonSource.exists,
path: jsonPath,
scope: jsonSource.exists ? jsonScope : null,
fields: []
},
// Additional info about both levels
projectMd: {
exists: projectExists,
path: projectPath
},
userMd: {
exists: userExists,
path: userPath
}
};
@@ -236,94 +439,140 @@ function getAgentSources(agentName) {
return sources;
}
function createAgent(agentName, config) {
function createAgent(agentName, config, workingDirectory, scope) {
ensureDirs();
const mdPath = path.join(AGENT_DIR, `${agentName}.md`);
if (fs.existsSync(mdPath)) {
throw new Error(`Agent ${agentName} already exists as .md file`);
// Check if agent already exists at either level
const projectPath = workingDirectory ? getProjectAgentPath(workingDirectory, agentName) : null;
const userPath = getUserAgentPath(agentName);
if (projectPath && fs.existsSync(projectPath)) {
throw new Error(`Agent ${agentName} already exists as project-level .md file`);
}
if (fs.existsSync(userPath)) {
throw new Error(`Agent ${agentName} already exists as user-level .md file`);
}
const existingConfig = readConfig();
if (existingConfig.agent?.[agentName]) {
const layers = readConfigLayers(workingDirectory);
const jsonSource = getJsonEntrySource(layers, 'agent', agentName);
if (jsonSource.exists) {
throw new Error(`Agent ${agentName} already exists in opencode.json`);
}
const { prompt, ...frontmatter } = config;
// Determine target path based on requested scope
let targetPath;
let targetScope;
if (scope === AGENT_SCOPE.PROJECT && workingDirectory) {
ensureProjectAgentDir(workingDirectory);
targetPath = projectPath;
targetScope = AGENT_SCOPE.PROJECT;
} else {
targetPath = userPath;
targetScope = AGENT_SCOPE.USER;
}
writeMdFile(mdPath, frontmatter, prompt || '');
console.log(`Created new agent: ${agentName}`);
// Extract scope and prompt from config - scope is only used for path determination, not written to file
const { prompt, scope: _scopeFromConfig, ...frontmatter } = config;
writeMdFile(targetPath, frontmatter, prompt || '');
console.log(`Created new agent: ${agentName} (scope: ${targetScope}, path: ${targetPath})`);
}
function updateAgent(agentName, updates) {
function updateAgent(agentName, updates, workingDirectory) {
ensureDirs();
const mdPath = path.join(AGENT_DIR, `${agentName}.md`);
const mdExists = fs.existsSync(mdPath);
// Determine correct path: project level takes precedence
const { scope, path: mdPath } = getAgentWritePath(agentName, workingDirectory);
const mdExists = mdPath && fs.existsSync(mdPath);
// Check if agent exists in opencode.json across all config layers
const layers = readConfigLayers(workingDirectory);
const jsonSource = getJsonEntrySource(layers, 'agent', agentName);
const jsonSection = jsonSource.section;
const hasJsonFields = jsonSource.exists && jsonSection && Object.keys(jsonSection).length > 0;
const jsonTarget = jsonSource.exists
? { config: jsonSource.config, path: jsonSource.path }
: getJsonWriteTarget(layers, workingDirectory ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER);
let config = jsonTarget.config || {};
// Determine if we should create a new md file:
// Only for built-in agents (no md file AND no json config)
const isBuiltinOverride = !mdExists && !hasJsonFields;
let targetPath = mdPath;
let targetScope = scope;
if (!mdExists && isBuiltinOverride) {
// Built-in agent override - create at user level
targetPath = getUserAgentPath(agentName);
targetScope = AGENT_SCOPE.USER;
}
let mdData = mdExists ? parseMdFile(mdPath) : null;
let config = readConfig();
const jsonSection = config.agent?.[agentName];
// Only create md data for existing md files or built-in overrides
let mdData = mdExists ? parseMdFile(mdPath) : (isBuiltinOverride ? { frontmatter: {}, body: '' } : null);
let mdModified = false;
let jsonModified = false;
// Only create new md if it's a built-in override
let creatingNewMd = isBuiltinOverride;
for (const [field, value] of Object.entries(updates)) {
if (field === 'prompt') {
const normalizedValue = typeof value === 'string' ? value : (value == null ? '' : String(value));
if (mdExists) {
mdData.body = normalizedValue;
mdModified = true;
if (mdExists || creatingNewMd) {
if (mdData) {
mdData.body = normalizedValue;
mdModified = true;
}
continue;
} else if (isPromptFileReference(jsonSection?.prompt)) {
const promptFilePath = resolvePromptFilePath(jsonSection.prompt);
if (!promptFilePath) {
throw new Error(`Invalid prompt file reference for agent ${agentName}`);
}
writePromptFile(promptFilePath, normalizedValue);
continue;
} else if (isPromptFileReference(normalizedValue)) {
if (!config.agent) config.agent = {};
if (!config.agent[agentName]) config.agent[agentName] = {};
config.agent[agentName].prompt = normalizedValue;
jsonModified = true;
} else {
if (!config.agent) config.agent = {};
if (!config.agent[agentName]) config.agent[agentName] = {};
config.agent[agentName].prompt = normalizedValue;
jsonModified = true;
continue;
}
// For JSON-only agents, store prompt inline in JSON
if (!config.agent) config.agent = {};
if (!config.agent[agentName]) config.agent[agentName] = {};
config.agent[agentName].prompt = normalizedValue;
jsonModified = true;
continue;
}
const inMd = mdData?.frontmatter?.[field] !== undefined;
const inJson = jsonSection?.[field] !== undefined;
if (inMd) {
mdData.frontmatter[field] = value;
mdModified = true;
} else if (inJson) {
// JSON takes precedence over md, so update JSON first if field exists there
if (inJson) {
if (!config.agent) config.agent = {};
if (!config.agent[agentName]) config.agent[agentName] = {};
config.agent[agentName][field] = value;
jsonModified = true;
} else if (inMd || creatingNewMd) {
if (mdData) {
mdData.frontmatter[field] = value;
mdModified = true;
}
} else {
if (mdExists && jsonSection) {
if (!config.agent) config.agent = {};
if (!config.agent[agentName]) config.agent[agentName] = {};
config.agent[agentName][field] = value;
jsonModified = true;
} else if (mdExists) {
// New field - add to the appropriate location based on agent source
if ((mdExists || creatingNewMd) && mdData) {
mdData.frontmatter[field] = value;
mdModified = true;
} else {
// JSON-only agent or has JSON fields - add to JSON
if (!config.agent) config.agent = {};
if (!config.agent[agentName]) config.agent[agentName] = {};
config.agent[agentName][field] = value;
@@ -332,39 +581,56 @@ function updateAgent(agentName, updates) {
}
}
if (mdModified) {
writeMdFile(mdPath, mdData.frontmatter, mdData.body);
if (mdModified && mdData) {
writeMdFile(targetPath, mdData.frontmatter, mdData.body);
}
if (jsonModified) {
writeConfig(config);
writeConfig(config, jsonTarget.path || CONFIG_FILE);
}
console.log(`Updated agent: ${agentName} (md: ${mdModified}, json: ${jsonModified})`);
console.log(`Updated agent: ${agentName} (scope: ${targetScope}, md: ${mdModified}, json: ${jsonModified})`);
}
function deleteAgent(agentName) {
const mdPath = path.join(AGENT_DIR, `${agentName}.md`);
function deleteAgent(agentName, workingDirectory) {
let deleted = false;
if (fs.existsSync(mdPath)) {
fs.unlinkSync(mdPath);
console.log(`Deleted agent .md file: ${mdPath}`);
// Check project level first (takes precedence)
if (workingDirectory) {
const projectPath = getProjectAgentPath(workingDirectory, agentName);
if (fs.existsSync(projectPath)) {
fs.unlinkSync(projectPath);
console.log(`Deleted project-level agent .md file: ${projectPath}`);
deleted = true;
}
}
// Then check user level
const userPath = getUserAgentPath(agentName);
if (fs.existsSync(userPath)) {
fs.unlinkSync(userPath);
console.log(`Deleted user-level agent .md file: ${userPath}`);
deleted = true;
}
const config = readConfig();
if (config.agent?.[agentName]) {
delete config.agent[agentName];
writeConfig(config);
// Also check json config (highest precedence entry only)
const layers = readConfigLayers(workingDirectory);
const jsonSource = getJsonEntrySource(layers, 'agent', agentName);
if (jsonSource.exists && jsonSource.config && jsonSource.path) {
if (!jsonSource.config.agent) jsonSource.config.agent = {};
delete jsonSource.config.agent[agentName];
writeConfig(jsonSource.config, jsonSource.path);
console.log(`Removed agent from opencode.json: ${agentName}`);
deleted = true;
}
// If nothing was deleted (built-in agent), disable it in highest-precedence config
if (!deleted) {
if (!config.agent) config.agent = {};
config.agent[agentName] = { disable: true };
writeConfig(config);
const jsonTarget = getJsonWriteTarget(layers, workingDirectory ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER);
const targetConfig = jsonTarget.config || {};
if (!targetConfig.agent) targetConfig.agent = {};
targetConfig.agent[agentName] = { disable: true };
writeConfig(targetConfig, jsonTarget.path || CONFIG_FILE);
console.log(`Disabled built-in agent: ${agentName}`);
}
}
@@ -383,8 +649,10 @@ function getCommandSources(commandName, workingDirectory) {
const mdExists = !!mdPath;
const mdScope = projectExists ? COMMAND_SCOPE.PROJECT : (userExists ? COMMAND_SCOPE.USER : null);
const config = readConfig();
const jsonSection = config.command?.[commandName];
const layers = readConfigLayers(workingDirectory);
const jsonSource = getJsonEntrySource(layers, 'command', commandName);
const jsonSection = jsonSource.section;
const jsonPath = jsonSource.path || layers.paths.customPath || layers.paths.projectPath || layers.paths.userPath;
const sources = {
md: {
@@ -394,8 +662,8 @@ function getCommandSources(commandName, workingDirectory) {
fields: []
},
json: {
exists: !!jsonSection,
path: CONFIG_FILE,
exists: jsonSource.exists,
path: jsonPath,
fields: []
},
// Additional info about both levels
@@ -439,8 +707,9 @@ function createCommand(commandName, config, workingDirectory, scope) {
throw new Error(`Command ${commandName} already exists as user-level .md file`);
}
const existingConfig = readConfig();
if (existingConfig.command?.[commandName]) {
const layers = readConfigLayers(workingDirectory);
const jsonSource = getJsonEntrySource(layers, 'command', commandName);
if (jsonSource.exists) {
throw new Error(`Command ${commandName} already exists in opencode.json`);
}
@@ -470,25 +739,33 @@ function updateCommand(commandName, updates, workingDirectory) {
// Determine correct path: project level takes precedence
const { scope, path: mdPath } = getCommandWritePath(commandName, workingDirectory);
const mdExists = mdPath && fs.existsSync(mdPath);
// If no existing md file, we need to create one (for built-in command overrides)
// Default to user level for built-in overrides
const layers = readConfigLayers(workingDirectory);
const jsonSource = getJsonEntrySource(layers, 'command', commandName);
const jsonSection = jsonSource.section;
const hasJsonFields = jsonSource.exists && jsonSection && Object.keys(jsonSection).length > 0;
const jsonTarget = jsonSource.exists
? { config: jsonSource.config, path: jsonSource.path }
: getJsonWriteTarget(layers, workingDirectory ? COMMAND_SCOPE.PROJECT : COMMAND_SCOPE.USER);
let config = jsonTarget.config || {};
// Only create a new md file for built-in overrides (no md + no json)
const isBuiltinOverride = !mdExists && !hasJsonFields;
let targetPath = mdPath;
let targetScope = scope;
if (!mdExists) {
// No existing md file - this is a built-in override, create at user level
if (!mdExists && isBuiltinOverride) {
// Built-in command override - create at user level
targetPath = getUserCommandPath(commandName);
targetScope = COMMAND_SCOPE.USER;
}
let mdData = mdExists ? parseMdFile(mdPath) : { frontmatter: {}, body: '' };
let config = readConfig();
const jsonSection = config.command?.[commandName];
const mdData = mdExists ? parseMdFile(mdPath) : (isBuiltinOverride ? { frontmatter: {}, body: '' } : null);
let mdModified = false;
let jsonModified = false;
let creatingNewMd = !mdExists;
let creatingNewMd = isBuiltinOverride;
for (const [field, value] of Object.entries(updates)) {
@@ -496,42 +773,51 @@ function updateCommand(commandName, updates, workingDirectory) {
const normalizedValue = typeof value === 'string' ? value : (value == null ? '' : String(value));
if (mdExists || creatingNewMd) {
mdData.body = normalizedValue;
mdModified = true;
if (mdData) {
mdData.body = normalizedValue;
mdModified = true;
}
continue;
} else if (isPromptFileReference(jsonSection?.template)) {
const templateFilePath = resolvePromptFilePath(jsonSection.template);
if (!templateFilePath) {
throw new Error(`Invalid template file reference for command ${commandName}`);
}
writePromptFile(templateFilePath, normalizedValue);
continue;
} else if (isPromptFileReference(normalizedValue)) {
if (!config.command) config.command = {};
if (!config.command[commandName]) config.command[commandName] = {};
config.command[commandName].template = normalizedValue;
jsonModified = true;
} else {
// Create new md file for the update
mdData.body = normalizedValue;
mdModified = true;
creatingNewMd = true;
continue;
}
// For JSON-only commands, store template inline in JSON
if (!config.command) config.command = {};
if (!config.command[commandName]) config.command[commandName] = {};
config.command[commandName].template = normalizedValue;
jsonModified = true;
continue;
}
const inMd = mdData?.frontmatter?.[field] !== undefined;
const inJson = jsonSection?.[field] !== undefined;
if (inMd || creatingNewMd) {
mdData.frontmatter[field] = value;
mdModified = true;
} else if (inJson) {
// JSON takes precedence over md, so update JSON first if field exists there
if (inJson) {
if (!config.command) config.command = {};
if (!config.command[commandName]) config.command[commandName] = {};
config.command[commandName][field] = value;
jsonModified = true;
} else if (inMd || creatingNewMd) {
if (mdData) {
mdData.frontmatter[field] = value;
mdModified = true;
}
} else {
// New field - add to md if it exists or we're creating one
if (mdExists || creatingNewMd) {
// New field - add to appropriate location based on command source
if ((mdExists || creatingNewMd) && mdData) {
mdData.frontmatter[field] = value;
mdModified = true;
} else {
@@ -543,12 +829,12 @@ function updateCommand(commandName, updates, workingDirectory) {
}
}
if (mdModified) {
if (mdModified && mdData) {
writeMdFile(targetPath, mdData.frontmatter, mdData.body);
}
if (jsonModified) {
writeConfig(config);
writeConfig(config, jsonTarget.path || CONFIG_FILE);
}
console.log(`Updated command: ${commandName} (scope: ${targetScope}, md: ${mdModified}, json: ${jsonModified})`);
@@ -575,11 +861,13 @@ function deleteCommand(commandName, workingDirectory) {
deleted = true;
}
// Also check json config
const config = readConfig();
if (config.command?.[commandName]) {
delete config.command[commandName];
writeConfig(config);
// Also check json config (highest precedence entry only)
const layers = readConfigLayers(workingDirectory);
const jsonSource = getJsonEntrySource(layers, 'command', commandName);
if (jsonSource.exists && jsonSource.config && jsonSource.path) {
if (!jsonSource.config.command) jsonSource.config.command = {};
delete jsonSource.config.command[commandName];
writeConfig(jsonSource.config, jsonSource.path);
console.log(`Removed command from opencode.json: ${commandName}`);
deleted = true;
}
@@ -591,6 +879,7 @@ function deleteCommand(commandName, workingDirectory) {
export {
getAgentSources,
getAgentScope,
createAgent,
updateAgent,
deleteAgent,
@@ -604,5 +893,6 @@ export {
AGENT_DIR,
COMMAND_DIR,
CONFIG_FILE,
AGENT_SCOPE,
COMMAND_SCOPE
};