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:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user