feat: add codex skills support and enhance system status UI

- Introduced new query keys for codex skills and their list.
- Updated English and Chinese locale files for system status messages.
- Enhanced the SettingsPage to display installation details and upgrade options.
- Integrated CLI mode toggle in SkillsManagerPage for better skill management.
- Modified skills routes to handle CLI type for skill operations and configurations.
This commit is contained in:
catlog22
2026-02-07 21:45:12 +08:00
parent 2094c1085b
commit 678be8d41f
14 changed files with 519 additions and 168 deletions

View File

@@ -8,7 +8,9 @@ export default defineConfig({
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:5173/react/',
// E2E runs the Vite dev server with a root base to keep route URLs stable in tests.
// (Many tests use absolute paths like `/sessions` which should resolve to the app router.)
baseURL: 'http://localhost:5173/',
trace: 'on-first-retry',
},
projects: [
@@ -27,7 +29,11 @@ export default defineConfig({
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173/react/',
url: 'http://localhost:5173/',
env: {
...process.env,
VITE_BASE_URL: '/',
},
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000,
},

View File

@@ -18,6 +18,8 @@ import {
MessageCircleQuestion,
ChevronDown,
ChevronRight,
Globe,
Folder,
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
@@ -31,6 +33,7 @@ import {
import { mcpServersKeys } from '@/hooks';
import { useQueryClient } from '@tanstack/react-query';
import { cn } from '@/lib/utils';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
// ========== Types ==========
@@ -103,16 +106,19 @@ export function CcwToolsMcpCard({
}: CcwToolsMcpCardProps) {
const { formatMessage } = useIntl();
const queryClient = useQueryClient();
const currentProjectPath = useWorkflowStore(selectProjectPath);
// Local state for config inputs
const [projectRootInput, setProjectRootInput] = useState(projectRoot || '');
const [allowedDirsInput, setAllowedDirsInput] = useState(allowedDirs || '');
const [disableSandboxInput, setDisableSandboxInput] = useState(disableSandbox || false);
const [isExpanded, setIsExpanded] = useState(false);
const [installScope, setInstallScope] = useState<'global' | 'project'>('global');
// Mutations for install/uninstall
const installMutation = useMutation({
mutationFn: installCcwMcp,
mutationFn: (params: { scope: 'global' | 'project'; projectPath?: string }) =>
installCcwMcp(params.scope, params.projectPath),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
queryClient.invalidateQueries({ queryKey: ['ccwMcpConfig'] });
@@ -167,7 +173,10 @@ export function CcwToolsMcpCard({
};
const handleInstallClick = () => {
installMutation.mutate();
installMutation.mutate({
scope: installScope,
projectPath: installScope === 'project' ? currentProjectPath : undefined,
});
};
const handleUninstallClick = () => {
@@ -257,7 +266,7 @@ export function CcwToolsMcpCard({
<p className="text-xs font-medium text-muted-foreground uppercase">
{formatMessage({ id: 'mcp.ccw.tools.label' })}
</p>
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
{CCW_MCP_TOOLS.map((tool) => {
const isEnabled = enabledTools.includes(tool.name);
const icon = getToolIcon(tool.name);
@@ -266,8 +275,8 @@ export function CcwToolsMcpCard({
<div
key={tool.name}
className={cn(
'flex items-center gap-3 p-2 rounded-lg transition-colors',
isEnabled ? 'bg-background' : 'bg-muted/50'
'flex items-center gap-3 p-2 rounded-lg transition-colors border',
isEnabled ? 'bg-background border-primary/40' : 'bg-background border-border'
)}
>
<input
@@ -381,7 +390,41 @@ export function CcwToolsMcpCard({
</div>
{/* Install/Uninstall Button */}
<div className="pt-3 border-t border-border">
<div className="pt-3 border-t border-border space-y-3">
{/* Scope Selection */}
{!isInstalled && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase">
{formatMessage({ id: 'mcp.scope' })}
</p>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="ccw-install-scope"
value="global"
checked={installScope === 'global'}
onChange={() => setInstallScope('global')}
className="w-4 h-4"
/>
<Globe className="w-4 h-4 text-muted-foreground" />
<span className="text-sm">{formatMessage({ id: 'mcp.scope.global' })}</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="ccw-install-scope"
value="project"
checked={installScope === 'project'}
onChange={() => setInstallScope('project')}
className="w-4 h-4"
/>
<Folder className="w-4 h-4 text-muted-foreground" />
<span className="text-sm">{formatMessage({ id: 'mcp.scope.project' })}</span>
</label>
</div>
</div>
)}
{!isInstalled ? (
<Button
onClick={handleInstallClick}

View File

@@ -37,6 +37,7 @@ export interface SkillCreateDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreated: () => void;
cliType?: 'claude' | 'codex';
}
type CreateMode = 'import' | 'cli-generate';
@@ -48,7 +49,7 @@ interface ValidationResult {
skillInfo?: { name: string; description: string; version?: string; supportingFiles?: string[] };
}
export function SkillCreateDialog({ open, onOpenChange, onCreated }: SkillCreateDialogProps) {
export function SkillCreateDialog({ open, onOpenChange, onCreated, cliType = 'claude' }: SkillCreateDialogProps) {
const { formatMessage } = useIntl();
const projectPath = useWorkflowStore(selectProjectPath);
@@ -125,6 +126,7 @@ export function SkillCreateDialog({ open, onOpenChange, onCreated }: SkillCreate
description: mode === 'cli-generate' ? description.trim() : undefined,
generationType: mode === 'cli-generate' ? 'description' : undefined,
projectPath,
cliType,
});
handleOpenChange(false);

View File

@@ -207,6 +207,8 @@ export {
useRefreshCodexCliEnhancement,
useCcwInstallStatus,
useCliToolStatus,
useCcwInstallations,
useUpgradeCcwInstallation,
systemSettingsKeys,
} from './useSystemSettings';
export type {
@@ -215,6 +217,7 @@ export type {
UseCodexCliEnhancementStatusReturn,
UseCcwInstallStatusReturn,
UseCliToolStatusReturn,
UseCcwInstallationsReturn,
} from './useSystemSettings';
// ========== CLI Execution ==========

View File

@@ -39,6 +39,7 @@ export interface UseSkillsOptions {
filter?: SkillsFilter;
staleTime?: number;
enabled?: boolean;
cliType?: 'claude' | 'codex';
}
export interface UseSkillsReturn {
@@ -61,15 +62,19 @@ export interface UseSkillsReturn {
* Hook for fetching and filtering skills
*/
export function useSkills(options: UseSkillsOptions = {}): UseSkillsReturn {
const { filter, staleTime = STALE_TIME, enabled = true } = options;
const { filter, staleTime = STALE_TIME, enabled = true, cliType = 'claude' } = options;
const queryClient = useQueryClient();
const projectPath = useWorkflowStore(selectProjectPath);
const queryKey = cliType === 'codex'
? workspaceQueryKeys.codexSkillsList(projectPath)
: workspaceQueryKeys.skillsList(projectPath);
const query = useQuery({
queryKey: workspaceQueryKeys.skillsList(projectPath),
queryFn: () => fetchSkills(projectPath),
queryKey,
queryFn: () => fetchSkills(projectPath, cliType),
staleTime,
enabled: enabled, // Remove projectPath requirement - API works without it
enabled: enabled,
retry: 2,
});
@@ -133,7 +138,10 @@ export function useSkills(options: UseSkillsOptions = {}): UseSkillsReturn {
const invalidate = async () => {
if (projectPath) {
await queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.skills(projectPath) });
const invalidateKey = cliType === 'codex'
? workspaceQueryKeys.codexSkills(projectPath)
: workspaceQueryKeys.skills(projectPath);
await queryClient.invalidateQueries({ queryKey: invalidateKey });
}
};
@@ -157,7 +165,7 @@ export function useSkills(options: UseSkillsOptions = {}): UseSkillsReturn {
// ========== Mutations ==========
export interface UseToggleSkillReturn {
toggleSkill: (skillName: string, enabled: boolean, location: 'project' | 'user') => Promise<Skill>;
toggleSkill: (skillName: string, enabled: boolean, location: 'project' | 'user', cliType?: 'claude' | 'codex') => Promise<Skill>;
isToggling: boolean;
error: Error | null;
}
@@ -168,10 +176,10 @@ export function useToggleSkill(): UseToggleSkillReturn {
const { addToast, removeToast, success, error } = useNotifications();
const mutation = useMutation({
mutationFn: ({ skillName, enabled, location }: { skillName: string; enabled: boolean; location: 'project' | 'user' }) =>
mutationFn: ({ skillName, enabled, location, cliType = 'claude' }: { skillName: string; enabled: boolean; location: 'project' | 'user'; cliType?: 'claude' | 'codex' }) =>
enabled
? enableSkill(skillName, location, projectPath)
: disableSkill(skillName, location, projectPath),
? enableSkill(skillName, location, projectPath, cliType)
: disableSkill(skillName, location, projectPath, cliType),
onMutate: (): { loadingId: string } => {
const loadingId = addToast('info', formatMessage('common.loading'), undefined, { duration: 0 });
return { loadingId };
@@ -183,7 +191,13 @@ export function useToggleSkill(): UseToggleSkillReturn {
const operation = variables.enabled ? 'skillEnable' : 'skillDisable';
success(formatMessage(`feedback.${operation}.success`));
queryClient.invalidateQueries({ queryKey: projectPath ? workspaceQueryKeys.skills(projectPath) : ['skills'] });
// Invalidate both claude and codex skills queries
if (projectPath) {
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.skills(projectPath) });
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.codexSkills(projectPath) });
} else {
queryClient.invalidateQueries({ queryKey: ['skills'] });
}
},
onError: (err, variables, context) => {
const { loadingId } = context ?? { loadingId: '' };
@@ -196,7 +210,7 @@ export function useToggleSkill(): UseToggleSkillReturn {
});
return {
toggleSkill: (skillName, enabled, location) => mutation.mutateAsync({ skillName, enabled, location }),
toggleSkill: (skillName, enabled, location, cliType) => mutation.mutateAsync({ skillName, enabled, location, cliType }),
isToggling: mutation.isPending,
error: mutation.error,
};

View File

@@ -14,10 +14,13 @@ import {
refreshCodexCliEnhancement,
fetchAggregatedStatus,
fetchCliToolStatus,
fetchCcwInstallations,
upgradeCcwInstallation,
type ChineseResponseStatus,
type WindowsPlatformStatus,
type CodexCliEnhancementStatus,
type CcwInstallStatus,
type CcwInstallationManifest,
} from '../lib/api';
// Query key factory
@@ -28,6 +31,7 @@ export const systemSettingsKeys = {
codexCliEnhancement: () => [...systemSettingsKeys.all, 'codexCliEnhancement'] as const,
aggregatedStatus: () => [...systemSettingsKeys.all, 'aggregatedStatus'] as const,
cliToolStatus: () => [...systemSettingsKeys.all, 'cliToolStatus'] as const,
ccwInstallations: () => [...systemSettingsKeys.all, 'ccwInstallations'] as const,
};
const STALE_TIME = 60 * 1000; // 1 minute
@@ -236,3 +240,48 @@ export function useCliToolStatus(): UseCliToolStatusReturn {
refetch: () => { query.refetch(); },
};
}
// ========================================
// CCW Installations Hooks
// ========================================
export interface UseCcwInstallationsReturn {
installations: CcwInstallationManifest[];
isLoading: boolean;
error: Error | null;
refetch: () => void;
}
export function useCcwInstallations(): UseCcwInstallationsReturn {
const query = useQuery({
queryKey: systemSettingsKeys.ccwInstallations(),
queryFn: fetchCcwInstallations,
staleTime: 5 * 60 * 1000, // 5 minutes
retry: 1,
});
return {
installations: query.data?.installations ?? [],
isLoading: query.isLoading,
error: query.error,
refetch: () => { query.refetch(); },
};
}
export function useUpgradeCcwInstallation() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (path?: string) => upgradeCcwInstallation(path),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: systemSettingsKeys.ccwInstallations() });
queryClient.invalidateQueries({ queryKey: systemSettingsKeys.aggregatedStatus() });
},
});
return {
upgrade: mutation.mutateAsync,
isPending: mutation.isPending,
error: mutation.error,
};
}

View File

@@ -992,7 +992,7 @@ export interface SkillsResponse {
* Fetch all skills for a specific workspace
* @param projectPath - Optional project path to filter data by workspace
*/
export async function fetchSkills(projectPath?: string): Promise<SkillsResponse> {
export async function fetchSkills(projectPath?: string, cliType: 'claude' | 'codex' = 'claude'): Promise<SkillsResponse> {
// Response type from backend when includeDisabled=true
interface ExtendedSkillsResponse {
skills?: Skill[];
@@ -1017,10 +1017,12 @@ export async function fetchSkills(projectPath?: string): Promise<SkillsResponse>
return [...projectSkillsEnabled, ...userSkillsEnabled, ...projectSkillsDisabled, ...userSkillsDisabled];
};
const cliTypeParam = cliType === 'codex' ? '&cliType=codex' : '';
// Try with project path first, fall back to global on 403/404
if (projectPath) {
try {
const url = `/api/skills?path=${encodeURIComponent(projectPath)}&includeDisabled=true`;
const url = `/api/skills?path=${encodeURIComponent(projectPath)}&includeDisabled=true${cliTypeParam}`;
const data = await fetchApi<ExtendedSkillsResponse>(url);
return { skills: buildSkillsList(data) };
} catch (error: unknown) {
@@ -1034,7 +1036,7 @@ export async function fetchSkills(projectPath?: string): Promise<SkillsResponse>
}
}
// Fallback: fetch global skills
const data = await fetchApi<ExtendedSkillsResponse>('/api/skills?includeDisabled=true');
const data = await fetchApi<ExtendedSkillsResponse>(`/api/skills?includeDisabled=true${cliTypeParam}`);
return { skills: buildSkillsList(data) };
}
@@ -1044,11 +1046,12 @@ export async function fetchSkills(projectPath?: string): Promise<SkillsResponse>
export async function enableSkill(
skillName: string,
location: 'project' | 'user',
projectPath?: string
projectPath?: string,
cliType: 'claude' | 'codex' = 'claude'
): Promise<Skill> {
return fetchApi<Skill>(`/api/skills/${encodeURIComponent(skillName)}/enable`, {
method: 'POST',
body: JSON.stringify({ location, projectPath }),
body: JSON.stringify({ location, projectPath, cliType }),
});
}
@@ -1058,11 +1061,12 @@ export async function enableSkill(
export async function disableSkill(
skillName: string,
location: 'project' | 'user',
projectPath?: string
projectPath?: string,
cliType: 'claude' | 'codex' = 'claude'
): Promise<Skill> {
return fetchApi<Skill>(`/api/skills/${encodeURIComponent(skillName)}/disable`, {
method: 'POST',
body: JSON.stringify({ location, projectPath }),
body: JSON.stringify({ location, projectPath, cliType }),
});
}
@@ -1075,11 +1079,13 @@ export async function disableSkill(
export async function fetchSkillDetail(
skillName: string,
location: 'project' | 'user',
projectPath?: string
projectPath?: string,
cliType: 'claude' | 'codex' = 'claude'
): Promise<{ skill: Skill }> {
const cliTypeParam = cliType === 'codex' ? '&cliType=codex' : '';
const url = projectPath
? `/api/skills/${encodeURIComponent(skillName)}?location=${location}&path=${encodeURIComponent(projectPath)}`
: `/api/skills/${encodeURIComponent(skillName)}?location=${location}`;
? `/api/skills/${encodeURIComponent(skillName)}?location=${location}&path=${encodeURIComponent(projectPath)}${cliTypeParam}`
: `/api/skills/${encodeURIComponent(skillName)}?location=${location}${cliTypeParam}`;
return fetchApi<{ skill: Skill }>(url);
}
@@ -1108,6 +1114,7 @@ export async function createSkill(params: {
description?: string;
generationType?: 'description' | 'template';
projectPath?: string;
cliType?: 'claude' | 'codex';
}): Promise<{ skillName: string; path: string }> {
return fetchApi('/api/skills/create', {
method: 'POST',
@@ -3030,6 +3037,21 @@ export async function createHook(
});
}
/**
* Save a hook to settings file via POST /api/hooks
* This writes directly to Claude Code's settings.json in the correct format
*/
export async function saveHook(
scope: 'global' | 'project',
event: string,
hookData: Record<string, unknown>
): Promise<{ success: boolean }> {
return fetchApi<{ success: boolean }>('/api/hooks', {
method: 'POST',
body: JSON.stringify({ projectPath: '', scope, event, hookData }),
});
}
/**
* Update hook using dedicated update endpoint with partial input
*/
@@ -3325,14 +3347,32 @@ export async function updateCcwConfig(config: {
/**
* Install CCW Tools MCP server
*/
export async function installCcwMcp(): Promise<CcwMcpConfig> {
export async function installCcwMcp(
scope: 'global' | 'project' = 'global',
projectPath?: string
): Promise<CcwMcpConfig> {
const serverConfig = buildCcwMcpServerConfig({
enabledTools: ['write_file', 'edit_file', 'read_file', 'core_memory', 'ask_question'],
});
const result = await addGlobalMcpServer('ccw-tools', serverConfig);
if (!result.success) {
throw new Error(result.error || 'Failed to install CCW MCP');
if (scope === 'project' && projectPath) {
const result = await fetchApi<{ success?: boolean; error?: string }>('/api/mcp-copy-server', {
method: 'POST',
body: JSON.stringify({
projectPath,
serverName: 'ccw-tools',
serverConfig,
configType: 'mcp',
}),
});
if (result?.error) {
throw new Error(result.error || 'Failed to install CCW MCP to project');
}
} else {
const result = await addGlobalMcpServer('ccw-tools', serverConfig);
if (!result.success) {
throw new Error(result.error || 'Failed to install CCW MCP');
}
}
return fetchCcwMcpConfig();
@@ -5242,3 +5282,38 @@ export async function fetchAggregatedStatus(): Promise<AggregatedStatus> {
export async function fetchCliToolStatus(): Promise<Record<string, { available: boolean; path?: string; version?: string }>> {
return fetchApi('/api/cli/status');
}
/**
* CCW Installation manifest
*/
export interface CcwInstallationManifest {
manifest_id: string;
version: string;
installation_mode: string;
installation_path: string;
installation_date: string;
installer_version: string;
manifest_file: string;
application_version: string;
files_count: number;
directories_count: number;
}
/**
* Fetch CCW installation manifests
*/
export async function fetchCcwInstallations(): Promise<{ installations: CcwInstallationManifest[] }> {
return fetchApi('/api/ccw/installations');
}
/**
* Upgrade CCW installation
*/
export async function upgradeCcwInstallation(
path?: string
): Promise<{ success: boolean; message?: string; error?: string; output?: string }> {
return fetchApi('/api/ccw/upgrade', {
method: 'POST',
body: JSON.stringify({ path }),
});
}

View File

@@ -48,6 +48,8 @@ export const workspaceQueryKeys = {
// ========== Skills ==========
skills: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'skills'] as const,
skillsList: (projectPath: string) => [...workspaceQueryKeys.skills(projectPath), 'list'] as const,
codexSkills: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'codexSkills'] as const,
codexSkillsList: (projectPath: string) => [...workspaceQueryKeys.codexSkills(projectPath), 'list'] as const,
// ========== Commands ==========
commands: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'commands'] as const,

View File

@@ -67,7 +67,17 @@
"disabled": "Disabled"
},
"systemStatus": {
"title": "System Status",
"title": "CCW Installation",
"installations": "installation(s)",
"noInstallations": "No installations detected",
"installPrompt": "Run ccw install to set up",
"global": "Global",
"path": "Path",
"version": "Version",
"files": "files",
"upgrade": "Upgrade",
"upgrading": "Upgrading...",
"refresh": "Refresh",
"ccwInstall": "CCW Installation",
"installed": "Installed",
"incomplete": "Incomplete",

View File

@@ -67,7 +67,17 @@
"disabled": "已禁用"
},
"systemStatus": {
"title": "系统状态",
"title": "CCW 安装",
"installations": "个安装",
"noInstallations": "未检测到安装",
"installPrompt": "运行 ccw install 进行安装",
"global": "Global",
"path": "Path",
"version": "版本",
"files": "files",
"upgrade": "升级",
"upgrading": "升级中...",
"refresh": "刷新",
"ccwInstall": "CCW 安装状态",
"installed": "已安装",
"incomplete": "不完整",

View File

@@ -22,6 +22,12 @@ import {
Monitor,
Terminal,
AlertTriangle,
Package,
Home,
Folder,
Calendar,
File,
ArrowUpCircle,
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
@@ -43,6 +49,8 @@ import {
useRefreshCodexCliEnhancement,
useCcwInstallStatus,
useCliToolStatus,
useCcwInstallations,
useUpgradeCcwInstallation,
} from '@/hooks/useSystemSettings';
// ========== CLI Tool Card Component ==========
@@ -517,51 +525,137 @@ function ResponseLanguageSection() {
function SystemStatusSection() {
const { formatMessage } = useIntl();
const { data: ccwInstall, isLoading: installLoading } = useCcwInstallStatus();
// Don't show if installed or still loading
if (installLoading || ccwInstall?.installed) return null;
const { installations, isLoading, refetch } = useCcwInstallations();
const { upgrade, isPending: upgrading } = useUpgradeCcwInstallation();
const { data: ccwInstall } = useCcwInstallStatus();
return (
<Card className="p-6 border-yellow-500/50">
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2 mb-4">
<AlertTriangle className="w-5 h-5 text-yellow-500" />
{formatMessage({ id: 'settings.systemStatus.title' })}
</h2>
{/* CCW Install Status */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{formatMessage({ id: 'settings.systemStatus.ccwInstall' })}</span>
<Badge variant="outline" className="text-yellow-500 border-yellow-500/50">
{formatMessage({ id: 'settings.systemStatus.incomplete' })}
</Badge>
<Card className="p-6">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2">
<Package className="w-5 h-5" />
{formatMessage({ id: 'settings.systemStatus.title' })}
{!isLoading && (
<span className="text-sm font-normal text-muted-foreground">
{installations.length} {formatMessage({ id: 'settings.systemStatus.installations' })}
</span>
)}
</h2>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => refetch()}
title={formatMessage({ id: 'settings.systemStatus.refresh' })}
>
<RefreshCw className={cn('w-3.5 h-3.5', isLoading && 'animate-spin')} />
</Button>
</div>
{ccwInstall && ccwInstall.missingFiles.length > 0 && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'settings.systemStatus.missingFiles' })}:
</p>
<ul className="text-xs text-muted-foreground/70 list-disc list-inside">
{ccwInstall.missingFiles.slice(0, 5).map((file) => (
<li key={file}>{file}</li>
))}
{ccwInstall.missingFiles.length > 5 && (
<li>+{ccwInstall.missingFiles.length - 5} more...</li>
)}
</ul>
<div className="bg-muted/50 rounded-md p-3 mt-2">
<p className="text-xs font-medium mb-1">
{formatMessage({ id: 'settings.systemStatus.runToFix' })}:
</p>
<code className="text-xs bg-background px-2 py-1 rounded block font-mono">
ccw install
</code>
</div>
</div>
)}
</div>
{/* Installation cards */}
{isLoading ? (
<div className="text-sm text-muted-foreground py-4 text-center">
{formatMessage({ id: 'settings.systemStatus.checking' })}
</div>
) : installations.length === 0 ? (
<div className="text-center py-6 space-y-2">
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'settings.systemStatus.noInstallations' })}
</p>
<div className="bg-muted/50 rounded-md p-3 inline-block">
<code className="text-xs font-mono">ccw install</code>
</div>
</div>
) : (
<div className="space-y-3">
{installations.map((inst) => {
const isGlobal = inst.installation_mode === 'Global';
const installDate = new Date(inst.installation_date).toLocaleDateString();
const version = inst.application_version !== 'unknown' ? inst.application_version : inst.installer_version;
return (
<div
key={inst.manifest_id}
className="rounded-lg border border-border p-4 space-y-2"
>
{/* Mode + Version + Upgrade */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={cn(
'inline-flex items-center justify-center w-8 h-8 rounded-lg',
isGlobal ? 'bg-primary/10 text-primary' : 'bg-orange-500/10 text-orange-500'
)}>
{isGlobal ? <Home className="w-4 h-4" /> : <Folder className="w-4 h-4" />}
</span>
<span className="text-sm font-medium">
{isGlobal
? formatMessage({ id: 'settings.systemStatus.global' })
: formatMessage({ id: 'settings.systemStatus.path' })}
</span>
<Badge variant="secondary" className="text-xs font-mono">
v{version}
</Badge>
</div>
<Button
variant="ghost"
size="sm"
className="h-7"
disabled={upgrading}
onClick={() => upgrade(inst.installation_path)}
>
<ArrowUpCircle className={cn('w-3.5 h-3.5 mr-1', upgrading && 'animate-spin')} />
{upgrading
? formatMessage({ id: 'settings.systemStatus.upgrading' })
: formatMessage({ id: 'settings.systemStatus.upgrade' })}
</Button>
</div>
{/* Path */}
<div className="text-xs text-muted-foreground bg-muted/50 rounded px-2 py-1 font-mono truncate" title={inst.installation_path}>
{inst.installation_path}
</div>
{/* Date + Files */}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1">
<Calendar className="w-3 h-3" />
{installDate}
</span>
<span className="inline-flex items-center gap-1">
<File className="w-3 h-3" />
{inst.files_count} {formatMessage({ id: 'settings.systemStatus.files' })}
</span>
</div>
</div>
);
})}
{/* Missing files warning */}
{ccwInstall && !ccwInstall.installed && ccwInstall.missingFiles.length > 0 && (
<div className="rounded-lg border border-yellow-500/50 bg-yellow-500/5 p-4 space-y-2">
<div className="flex items-center gap-2 text-sm font-medium text-yellow-600 dark:text-yellow-500">
<AlertTriangle className="w-4 h-4" />
{formatMessage({ id: 'settings.systemStatus.incomplete' })} &mdash; {ccwInstall.missingFiles.length} {formatMessage({ id: 'settings.systemStatus.missingFiles' }).toLowerCase()}
</div>
<ul className="text-xs text-muted-foreground list-disc list-inside">
{ccwInstall.missingFiles.slice(0, 4).map((f) => (
<li key={f}>{f}</li>
))}
{ccwInstall.missingFiles.length > 4 && (
<li>+{ccwInstall.missingFiles.length - 4} more...</li>
)}
</ul>
<div className="bg-muted/50 rounded-md p-2">
<p className="text-xs font-medium mb-1">{formatMessage({ id: 'settings.systemStatus.runToFix' })}:</p>
<code className="text-xs font-mono bg-background px-2 py-1 rounded block">ccw install</code>
</div>
</div>
)}
</div>
)}
</Card>
);
}
@@ -865,31 +959,6 @@ export function SettingsPage() {
</div>
</Card>
{/* Display Settings */}
<div className="py-4">
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2 mb-4">
<Settings className="w-5 h-5" />
{formatMessage({ id: 'settings.sections.display' })}
</h2>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-foreground">{formatMessage({ id: 'settings.display.showCompletedTasks' })}</p>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'settings.display.showCompletedTasksDesc' })}
</p>
</div>
<Button
variant={userPreferences.showCompletedTasks ? 'default' : 'outline'}
size="sm"
onClick={() => handlePreferenceChange('showCompletedTasks', !userPreferences.showCompletedTasks)}
>
{userPreferences.showCompletedTasks ? formatMessage({ id: 'settings.display.show' }) : formatMessage({ id: 'settings.display.hide' })}
</Button>
</div>
</div>
</div>
{/* Reset Settings */}
<Card className="p-6 border-destructive/50">
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2 mb-4">

View File

@@ -39,6 +39,7 @@ import {
AlertDialogCancel,
} from '@/components/ui';
import { SkillCard, SkillDetailPanel, SkillCreateDialog } from '@/components/shared';
import { CliModeToggle, type CliMode } from '@/components/mcp/CliModeToggle';
import { useSkills, useSkillMutations } from '@/hooks';
import { fetchSkillDetail } from '@/lib/api';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
@@ -109,6 +110,7 @@ export function SkillsManagerPage() {
const { formatMessage } = useIntl();
const projectPath = useWorkflowStore(selectProjectPath);
const [cliMode, setCliMode] = useState<CliMode>('claude');
const [searchQuery, setSearchQuery] = useState('');
const [categoryFilter, setCategoryFilter] = useState<string>('all');
const [sourceFilter, setSourceFilter] = useState<string>('all');
@@ -143,6 +145,7 @@ export function SkillsManagerPage() {
enabledOnly: enabledFilter === 'enabled',
location: locationFilter,
},
cliType: cliMode,
});
const { toggleSkill, isToggling } = useSkillMutations();
@@ -165,18 +168,19 @@ export function SkillsManagerPage() {
const location = skill.location || 'project';
// Use folderName for API calls (actual folder name), fallback to name if not available
const skillIdentifier = skill.folderName || skill.name;
// Debug logging
console.log('[SkillToggle] Toggling skill:', {
name: skill.name,
folderName: skill.folderName,
location,
enabled,
skillIdentifier
console.log('[SkillToggle] Toggling skill:', {
name: skill.name,
folderName: skill.folderName,
location,
enabled,
skillIdentifier,
cliMode
});
try {
await toggleSkill(skillIdentifier, enabled, location);
await toggleSkill(skillIdentifier, enabled, location, cliMode);
} catch (error) {
console.error('[SkillToggle] Toggle failed:', error);
throw error;
@@ -211,7 +215,8 @@ export function SkillsManagerPage() {
const data = await fetchSkillDetail(
skill.name,
skill.location || 'project',
projectPath
projectPath,
cliMode
);
setSelectedSkill(data.skill);
} catch (error) {
@@ -220,7 +225,7 @@ export function SkillsManagerPage() {
} finally {
setIsDetailLoading(false);
}
}, [projectPath]);
}, [projectPath, cliMode]);
const handleCloseDetailPanel = useCallback(() => {
setIsDetailPanelOpen(false);
@@ -232,14 +237,23 @@ export function SkillsManagerPage() {
{/* Page Header */}
<div className="flex flex-col gap-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
<Sparkles className="w-6 h-6 text-primary" />
{formatMessage({ id: 'skills.title' })}
</h1>
<p className="text-muted-foreground mt-1">
{formatMessage({ id: 'skills.description' })}
</p>
<div className="flex items-center gap-3">
<div>
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
<Sparkles className="w-6 h-6 text-primary" />
{formatMessage({ id: 'skills.title' })}
</h1>
<p className="text-muted-foreground mt-1">
{formatMessage({ id: 'skills.description' })}
</p>
</div>
{/* CLI Mode Badge Switcher */}
<div className="ml-3 flex-shrink-0">
<CliModeToggle
currentMode={cliMode}
onModeChange={setCliMode}
/>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => refetch()} disabled={isFetching}>
@@ -479,6 +493,7 @@ export function SkillsManagerPage() {
open={isCreateDialogOpen}
onOpenChange={setIsCreateDialogOpen}
onCreated={() => refetch()}
cliType={cliMode}
/>
</div>
);