feat(cli-endpoints): add create, update, and delete functionality for CLI endpoints

- Implemented `useCreateCliEndpoint`, `useUpdateCliEndpoint`, and `useDeleteCliEndpoint` hooks for managing CLI endpoints.
- Added `CliEndpointFormDialog` component for creating and editing CLI endpoints with validation.
- Updated translations for CLI hooks and manager to include new fields and messages.
- Refactored `CcwToolsMcpCard` to simplify enabling and disabling tools.
- Adjusted `SkillCreateDialog` to display paths based on CLI type.
This commit is contained in:
catlog22
2026-02-07 21:56:08 +08:00
parent 678be8d41f
commit 6073627ff2
12 changed files with 1252 additions and 422 deletions

View File

@@ -0,0 +1,270 @@
// ========================================
// CLI Endpoint Form Dialog
// ========================================
// Dialog for creating and editing CLI endpoints
import { useEffect, useMemo, useState } from 'react';
import { useIntl } from 'react-intl';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/Dialog';
import { Input } from '@/components/ui/Input';
import { Textarea } from '@/components/ui/Textarea';
import { Button } from '@/components/ui/Button';
import { Label } from '@/components/ui/Label';
import { Switch } from '@/components/ui/Switch';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/Select';
import type { CliEndpoint } from '@/lib/api';
export type CliEndpointFormMode = 'create' | 'edit';
export interface CliEndpointSavePayload {
name: string;
type: CliEndpoint['type'];
enabled: boolean;
config: Record<string, unknown>;
}
export interface CliEndpointFormDialogProps {
mode: CliEndpointFormMode;
endpoint?: CliEndpoint;
open: boolean;
onClose: () => void;
onSave: (payload: CliEndpointSavePayload) => Promise<void>;
}
interface FormErrors {
name?: string;
type?: string;
configJson?: string;
}
function safeStringifyConfig(config: unknown): string {
try {
return JSON.stringify(config ?? {}, null, 2);
} catch {
return '{}';
}
}
function parseConfigJson(
configJson: string
): { ok: true; value: Record<string, unknown> } | { ok: false; errorKey: string } {
const trimmed = configJson.trim();
if (!trimmed) {
return { ok: true, value: {} };
}
try {
const parsed = JSON.parse(trimmed) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return { ok: false, errorKey: 'validation.configMustBeObject' };
}
return { ok: true, value: parsed as Record<string, unknown> };
} catch {
return { ok: false, errorKey: 'validation.invalidJson' };
}
}
export function CliEndpointFormDialog({
mode,
endpoint,
open,
onClose,
onSave,
}: CliEndpointFormDialogProps) {
const { formatMessage } = useIntl();
const isEditing = mode === 'edit';
const [name, setName] = useState('');
const [type, setType] = useState<CliEndpoint['type']>('custom');
const [enabled, setEnabled] = useState(true);
const [configJson, setConfigJson] = useState('{}');
const [errors, setErrors] = useState<FormErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const dialogTitle = useMemo(() => {
return isEditing
? formatMessage({ id: 'cliEndpoints.dialog.editTitle' }, { id: endpoint?.id ?? '' })
: formatMessage({ id: 'cliEndpoints.dialog.createTitle' });
}, [formatMessage, isEditing, endpoint?.id]);
useEffect(() => {
if (!open) return;
if (isEditing && endpoint) {
setName(endpoint.name);
setType(endpoint.type);
setEnabled(endpoint.enabled);
setConfigJson(safeStringifyConfig(endpoint.config));
} else {
setName('');
setType('custom');
setEnabled(true);
setConfigJson('{}');
}
setErrors({});
setIsSubmitting(false);
}, [open, isEditing, endpoint]);
const handleSubmit = async () => {
const nextErrors: FormErrors = {};
if (!name.trim()) {
nextErrors.name = 'validation.nameRequired';
}
if (!type) {
nextErrors.type = 'validation.typeRequired';
}
const parsedConfig = parseConfigJson(configJson);
if (!parsedConfig.ok) {
nextErrors.configJson = parsedConfig.errorKey;
}
if (Object.keys(nextErrors).length > 0) {
setErrors(nextErrors);
return;
}
setIsSubmitting(true);
try {
await onSave({
name: name.trim(),
type,
enabled,
config: (parsedConfig as { ok: true; value: Record<string, unknown> }).value,
});
onClose();
} catch (err) {
console.error('Failed to save CLI endpoint:', err);
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[720px]">
<DialogHeader>
<DialogTitle>{dialogTitle}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
{isEditing && endpoint && (
<div className="space-y-2">
<Label htmlFor="cli-endpoint-id">{formatMessage({ id: 'cliEndpoints.id' })}</Label>
<Input
id="cli-endpoint-id"
value={endpoint.id}
disabled
className="font-mono"
/>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="cli-endpoint-name">
{formatMessage({ id: 'cliEndpoints.form.name' })} *
</Label>
<Input
id="cli-endpoint-name"
value={name}
onChange={(e) => {
setName(e.target.value);
if (errors.name) setErrors((prev) => ({ ...prev, name: undefined }));
}}
placeholder={formatMessage({ id: 'cliEndpoints.form.namePlaceholder' })}
error={!!errors.name}
/>
{errors.name && (
<p className="text-xs text-destructive">
{formatMessage({ id: `cliEndpoints.${errors.name}` })}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="cli-endpoint-type">
{formatMessage({ id: 'cliEndpoints.form.type' })} *
</Label>
<Select
value={type}
onValueChange={(v) => {
setType(v as CliEndpoint['type']);
if (errors.type) setErrors((prev) => ({ ...prev, type: undefined }));
}}
>
<SelectTrigger id="cli-endpoint-type" className={errors.type ? 'border-destructive' : undefined}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="litellm">{formatMessage({ id: 'cliEndpoints.type.litellm' })}</SelectItem>
<SelectItem value="custom">{formatMessage({ id: 'cliEndpoints.type.custom' })}</SelectItem>
<SelectItem value="wrapper">{formatMessage({ id: 'cliEndpoints.type.wrapper' })}</SelectItem>
</SelectContent>
</Select>
{errors.type && (
<p className="text-xs text-destructive">
{formatMessage({ id: `cliEndpoints.${errors.type}` })}
</p>
)}
</div>
</div>
<div className="flex items-center justify-between rounded-md border border-border p-3">
<div>
<p className="text-sm font-medium text-foreground">{formatMessage({ id: 'cliEndpoints.form.enabled' })}</p>
<p className="text-xs text-muted-foreground">{formatMessage({ id: 'cliEndpoints.form.enabledHint' })}</p>
</div>
<Switch checked={enabled} onCheckedChange={setEnabled} />
</div>
<div className="space-y-2">
<Label htmlFor="cli-endpoint-config">{formatMessage({ id: 'cliEndpoints.form.configJson' })}</Label>
<Textarea
id="cli-endpoint-config"
value={configJson}
onChange={(e) => {
setConfigJson(e.target.value);
if (errors.configJson) setErrors((prev) => ({ ...prev, configJson: undefined }));
}}
placeholder={formatMessage({ id: 'cliEndpoints.form.configJsonPlaceholder' })}
className={errors.configJson ? 'font-mono border-destructive' : 'font-mono'}
rows={10}
/>
{errors.configJson && (
<p className="text-xs text-destructive">
{formatMessage({ id: `cliEndpoints.${errors.configJson}` })}
</p>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={isSubmitting}>
{formatMessage({ id: 'common.actions.cancel' })}
</Button>
<Button onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting
? formatMessage({ id: 'common.actions.saving' })
: formatMessage({ id: 'common.actions.save' })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default CliEndpointFormDialog;

File diff suppressed because it is too large Load Diff

View File

@@ -151,17 +151,12 @@ export function CcwToolsMcpCard({
};
const handleEnableAll = () => {
CCW_MCP_TOOLS.forEach((tool) => {
if (!enabledTools.includes(tool.name)) {
onToggleTool(tool.name, true);
}
});
const allToolNames = CCW_MCP_TOOLS.map((t) => t.name);
onUpdateConfig({ enabledTools: allToolNames });
};
const handleDisableAll = () => {
enabledTools.forEach((toolName) => {
onToggleTool(toolName, false);
});
onUpdateConfig({ enabledTools: [] });
};
const handleConfigSave = () => {

View File

@@ -177,7 +177,7 @@ export function SkillCreateDialog({ open, onOpenChange, onCreated, cliType = 'cl
<Folder className="w-5 h-5" />
<div>
<div className="font-medium text-sm">{formatMessage({ id: 'skills.create.locationProject' })}</div>
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'skills.create.locationProjectHint' })}</div>
<div className="text-xs text-muted-foreground">{`.${cliType}/skills/`}</div>
</div>
</div>
</button>
@@ -195,7 +195,7 @@ export function SkillCreateDialog({ open, onOpenChange, onCreated, cliType = 'cl
<User className="w-5 h-5" />
<div>
<div className="font-medium text-sm">{formatMessage({ id: 'skills.create.locationUser' })}</div>
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'skills.create.locationUserHint' })}</div>
<div className="text-xs text-muted-foreground">{`~/.${cliType}/skills/`}</div>
</div>
</div>
</button>