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>
);

View File

@@ -22,6 +22,12 @@ import type {
} from '../../types/skill-types.js';
type GenerationType = 'description' | 'template';
type CliType = 'claude' | 'codex';
/** Get the CLI base directory name based on cliType */
function getCliDir(cliType: CliType): string {
return cliType === 'codex' ? '.codex' : '.claude';
}
interface GenerationParams {
generationType: GenerationType;
@@ -30,6 +36,7 @@ interface GenerationParams {
location: SkillLocation;
projectPath: string;
broadcastToClients?: (data: unknown) => void;
cliType?: CliType;
}
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -46,7 +53,8 @@ async function disableSkill(
location: SkillLocation,
projectPath: string,
initialPath: string,
reason?: string // Kept for API compatibility but no longer used
reason?: string, // Kept for API compatibility but no longer used
cliType: CliType = 'claude'
): Promise<SkillOperationResult> {
try {
// Validate skill name
@@ -54,18 +62,20 @@ async function disableSkill(
return { success: false, message: 'Invalid skill name', status: 400 };
}
const cliDir = getCliDir(cliType);
// Get skill directory
let skillsDir: string;
if (location === 'project') {
try {
const validatedProjectPath = await validateAllowedPath(projectPath, { mustExist: true, allowedDirectories: [initialPath] });
skillsDir = join(validatedProjectPath, '.claude', 'skills');
skillsDir = join(validatedProjectPath, cliDir, 'skills');
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { success: false, message: message.includes('Access denied') ? 'Access denied' : 'Invalid path', status: 403 };
}
} else {
skillsDir = join(homedir(), '.claude', 'skills');
skillsDir = join(homedir(), cliDir, 'skills');
}
const skillDir = join(skillsDir, skillName);
@@ -99,7 +109,8 @@ async function enableSkill(
skillName: string,
location: SkillLocation,
projectPath: string,
initialPath: string
initialPath: string,
cliType: CliType = 'claude'
): Promise<SkillOperationResult> {
try {
// Validate skill name
@@ -107,18 +118,20 @@ async function enableSkill(
return { success: false, message: 'Invalid skill name', status: 400 };
}
const cliDir = getCliDir(cliType);
// Get skill directory
let skillsDir: string;
if (location === 'project') {
try {
const validatedProjectPath = await validateAllowedPath(projectPath, { mustExist: true, allowedDirectories: [initialPath] });
skillsDir = join(validatedProjectPath, '.claude', 'skills');
skillsDir = join(validatedProjectPath, cliDir, 'skills');
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { success: false, message: message.includes('Access denied') ? 'Access denied' : 'Invalid path', status: 403 };
}
} else {
skillsDir = join(homedir(), '.claude', 'skills');
skillsDir = join(homedir(), cliDir, 'skills');
}
const skillDir = join(skillsDir, skillName);
@@ -148,15 +161,17 @@ async function enableSkill(
/**
* Get list of disabled skills by checking for SKILL.md.disabled files
*/
function getDisabledSkillsList(location: SkillLocation, projectPath: string): DisabledSkillSummary[] {
function getDisabledSkillsList(location: SkillLocation, projectPath: string, cliType: CliType = 'claude'): DisabledSkillSummary[] {
const result: DisabledSkillSummary[] = [];
const cliDir = getCliDir(cliType);
// Get skills directory (not a separate disabled directory)
let skillsDir: string;
if (location === 'project') {
skillsDir = join(projectPath, '.claude', 'skills');
skillsDir = join(projectPath, cliDir, 'skills');
} else {
skillsDir = join(homedir(), '.claude', 'skills');
skillsDir = join(homedir(), cliDir, 'skills');
}
if (!existsSync(skillsDir)) {
@@ -199,12 +214,12 @@ function getDisabledSkillsList(location: SkillLocation, projectPath: string): Di
/**
* Get extended skills config including disabled skills
*/
function getExtendedSkillsConfig(projectPath: string): ExtendedSkillsConfig {
const baseConfig = getSkillsConfig(projectPath);
function getExtendedSkillsConfig(projectPath: string, cliType: CliType = 'claude'): ExtendedSkillsConfig {
const baseConfig = getSkillsConfig(projectPath, cliType);
return {
...baseConfig,
disabledProjectSkills: getDisabledSkillsList('project', projectPath),
disabledUserSkills: getDisabledSkillsList('user', projectPath)
disabledProjectSkills: getDisabledSkillsList('project', projectPath, cliType),
disabledUserSkills: getDisabledSkillsList('user', projectPath, cliType)
};
}
@@ -293,15 +308,17 @@ function getSupportingFiles(skillDir: string): string[] {
* @param {string} projectPath
* @returns {Object}
*/
function getSkillsConfig(projectPath: string): SkillsConfig {
function getSkillsConfig(projectPath: string, cliType: CliType = 'claude'): SkillsConfig {
const result: SkillsConfig = {
projectSkills: [],
userSkills: []
};
const cliDir = getCliDir(cliType);
try {
// Project skills: .claude/skills/
const projectSkillsDir = join(projectPath, '.claude', 'skills');
// Project skills: .<cliType>/skills/
const projectSkillsDir = join(projectPath, cliDir, 'skills');
if (existsSync(projectSkillsDir)) {
const skills = readdirSync(projectSkillsDir, { withFileTypes: true });
for (const skill of skills) {
@@ -330,8 +347,8 @@ function getSkillsConfig(projectPath: string): SkillsConfig {
}
}
// User skills: ~/.claude/skills/
const userSkillsDir = join(homedir(), '.claude', 'skills');
// User skills: ~/.<cliType>/skills/
const userSkillsDir = join(homedir(), cliDir, 'skills');
if (existsSync(userSkillsDir)) {
const skills = readdirSync(userSkillsDir, { withFileTypes: true });
for (const skill of skills) {
@@ -373,7 +390,7 @@ function getSkillsConfig(projectPath: string): SkillsConfig {
* @param {string} projectPath
* @returns {Object}
*/
async function getSkillDetail(skillName: string, location: SkillLocation, projectPath: string, initialPath: string) {
async function getSkillDetail(skillName: string, location: SkillLocation, projectPath: string, initialPath: string, cliType: CliType = 'claude') {
try {
if (skillName.includes('/') || skillName.includes('\\')) {
return { error: 'Access denied', status: 403 };
@@ -382,11 +399,13 @@ async function getSkillDetail(skillName: string, location: SkillLocation, projec
return { error: 'Invalid skill name', status: 400 };
}
const cliDir = getCliDir(cliType);
let baseDir;
if (location === 'project') {
try {
const validatedProjectPath = await validateAllowedPath(projectPath, { mustExist: true, allowedDirectories: [initialPath] });
baseDir = join(validatedProjectPath, '.claude', 'skills');
baseDir = join(validatedProjectPath, cliDir, 'skills');
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const status = message.includes('Access denied') ? 403 : 400;
@@ -394,7 +413,7 @@ async function getSkillDetail(skillName: string, location: SkillLocation, projec
return { error: status === 403 ? 'Access denied' : 'Invalid path', status };
}
} else {
baseDir = join(homedir(), '.claude', 'skills');
baseDir = join(homedir(), cliDir, 'skills');
}
const skillDir = join(baseDir, skillName);
@@ -442,7 +461,7 @@ async function getSkillDetail(skillName: string, location: SkillLocation, projec
* @param {string} projectPath
* @returns {Object}
*/
async function deleteSkill(skillName: string, location: SkillLocation, projectPath: string, initialPath: string) {
async function deleteSkill(skillName: string, location: SkillLocation, projectPath: string, initialPath: string, cliType: CliType = 'claude') {
try {
if (skillName.includes('/') || skillName.includes('\\')) {
return { error: 'Access denied', status: 403 };
@@ -451,11 +470,13 @@ async function deleteSkill(skillName: string, location: SkillLocation, projectPa
return { error: 'Invalid skill name', status: 400 };
}
const cliDir = getCliDir(cliType);
let baseDir;
if (location === 'project') {
try {
const validatedProjectPath = await validateAllowedPath(projectPath, { mustExist: true, allowedDirectories: [initialPath] });
baseDir = join(validatedProjectPath, '.claude', 'skills');
baseDir = join(validatedProjectPath, cliDir, 'skills');
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const status = message.includes('Access denied') ? 403 : 400;
@@ -463,7 +484,7 @@ async function deleteSkill(skillName: string, location: SkillLocation, projectPa
return { error: status === 403 ? 'Access denied' : 'Invalid path', status };
}
} else {
baseDir = join(homedir(), '.claude', 'skills');
baseDir = join(homedir(), cliDir, 'skills');
}
const skillDirCandidate = join(baseDir, skillName);
@@ -585,7 +606,7 @@ async function copyDirectoryRecursive(source: string, target: string): Promise<v
* @param {string} customName - Optional custom name for skill
* @returns {Object}
*/
async function importSkill(sourcePath: string, location: SkillLocation, projectPath: string, customName?: string) {
async function importSkill(sourcePath: string, location: SkillLocation, projectPath: string, customName?: string, cliType: CliType = 'claude') {
try {
// Validate source folder
const validation = validateSkillFolder(sourcePath);
@@ -593,9 +614,10 @@ async function importSkill(sourcePath: string, location: SkillLocation, projectP
return { error: validation.errors.join(', ') };
}
const cliDir = getCliDir(cliType);
const baseDir = location === 'project'
? join(projectPath, '.claude', 'skills')
: join(homedir(), '.claude', 'skills');
? join(projectPath, cliDir, 'skills')
: join(homedir(), cliDir, 'skills');
// Ensure base directory exists
if (!existsSync(baseDir)) {
@@ -639,7 +661,7 @@ async function importSkill(sourcePath: string, location: SkillLocation, projectP
* @param {Function} params.broadcastToClients - WebSocket broadcast function
* @returns {Object}
*/
async function generateSkillViaCLI({ generationType, description, skillName, location, projectPath, broadcastToClients }: GenerationParams) {
async function generateSkillViaCLI({ generationType, description, skillName, location, projectPath, broadcastToClients, cliType = 'claude' }: GenerationParams) {
// Generate unique execution ID for tracking
const executionId = `skill-gen-${skillName}-${Date.now()}`;
@@ -652,10 +674,12 @@ async function generateSkillViaCLI({ generationType, description, skillName, loc
return { error: 'Description is required for description-based generation' };
}
const cliDir = getCliDir(cliType);
// Determine target directory
const baseDir = location === 'project'
? join(projectPath, '.claude', 'skills')
: join(homedir(), '.claude', 'skills');
? join(projectPath, cliDir, 'skills')
: join(homedir(), cliDir, 'skills');
const targetPath = join(baseDir, skillName);
@@ -810,16 +834,18 @@ export async function handleSkillsRoutes(ctx: RouteContext): Promise<boolean> {
if (pathname === '/api/skills' && req.method === 'GET') {
const projectPathParam = url.searchParams.get('path') || initialPath;
const includeDisabled = url.searchParams.get('includeDisabled') === 'true';
const cliTypeParam = url.searchParams.get('cliType');
const cliType: CliType = cliTypeParam === 'codex' ? 'codex' : 'claude';
try {
const validatedProjectPath = await validateAllowedPath(projectPathParam, { mustExist: true, allowedDirectories: [initialPath] });
if (includeDisabled) {
const extendedData = getExtendedSkillsConfig(validatedProjectPath);
const extendedData = getExtendedSkillsConfig(validatedProjectPath, cliType);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(extendedData));
} else {
const skillsData = getSkillsConfig(validatedProjectPath);
const skillsData = getSkillsConfig(validatedProjectPath, cliType);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(skillsData));
}
@@ -836,11 +862,13 @@ export async function handleSkillsRoutes(ctx: RouteContext): Promise<boolean> {
// API: Get disabled skills list
if (pathname === '/api/skills/disabled' && req.method === 'GET') {
const projectPathParam = url.searchParams.get('path') || initialPath;
const cliTypeParam = url.searchParams.get('cliType');
const cliType: CliType = cliTypeParam === 'codex' ? 'codex' : 'claude';
try {
const validatedProjectPath = await validateAllowedPath(projectPathParam, { mustExist: true, allowedDirectories: [initialPath] });
const disabledProjectSkills = getDisabledSkillsList('project', validatedProjectPath);
const disabledUserSkills = getDisabledSkillsList('user', validatedProjectPath);
const disabledProjectSkills = getDisabledSkillsList('project', validatedProjectPath, cliType);
const disabledUserSkills = getDisabledSkillsList('user', validatedProjectPath, cliType);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ disabledProjectSkills, disabledUserSkills }));
@@ -872,7 +900,9 @@ export async function handleSkillsRoutes(ctx: RouteContext): Promise<boolean> {
}
const projectPath = projectPathParam || initialPath;
return disableSkill(skillName, locationValue, projectPath, initialPath, reason);
const cliTypeValue = typeof body.cliType === 'string' ? body.cliType : 'claude';
const cliType: CliType = cliTypeValue === 'codex' ? 'codex' : 'claude';
return disableSkill(skillName, locationValue, projectPath, initialPath, reason, cliType);
});
return true;
}
@@ -895,7 +925,9 @@ export async function handleSkillsRoutes(ctx: RouteContext): Promise<boolean> {
}
const projectPath = projectPathParam || initialPath;
return enableSkill(skillName, locationValue, projectPath, initialPath);
const cliTypeValue = typeof body.cliType === 'string' ? body.cliType : 'claude';
const cliType: CliType = cliTypeValue === 'codex' ? 'codex' : 'claude';
return enableSkill(skillName, locationValue, projectPath, initialPath, cliType);
});
return true;
}
@@ -907,6 +939,9 @@ export async function handleSkillsRoutes(ctx: RouteContext): Promise<boolean> {
const subPath = url.searchParams.get('subpath') || '';
const location = url.searchParams.get('location') || 'project';
const projectPathParam = url.searchParams.get('path') || initialPath;
const cliTypeParam = url.searchParams.get('cliType');
const dirCliType: CliType = cliTypeParam === 'codex' ? 'codex' : 'claude';
const dirCliDir = getCliDir(dirCliType);
if (skillName.includes('/') || skillName.includes('\\') || skillName.includes('..')) {
res.writeHead(400, { 'Content-Type': 'application/json' });
@@ -918,7 +953,7 @@ export async function handleSkillsRoutes(ctx: RouteContext): Promise<boolean> {
if (location === 'project') {
try {
const validatedProjectPath = await validateAllowedPath(projectPathParam, { mustExist: true, allowedDirectories: [initialPath] });
baseDir = join(validatedProjectPath, '.claude', 'skills');
baseDir = join(validatedProjectPath, dirCliDir, 'skills');
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const status = message.includes('Access denied') ? 403 : 400;
@@ -928,7 +963,7 @@ export async function handleSkillsRoutes(ctx: RouteContext): Promise<boolean> {
return true;
}
} else {
baseDir = join(homedir(), '.claude', 'skills');
baseDir = join(homedir(), dirCliDir, 'skills');
}
const skillRoot = join(baseDir, skillName);
@@ -988,6 +1023,9 @@ export async function handleSkillsRoutes(ctx: RouteContext): Promise<boolean> {
const fileName = url.searchParams.get('filename');
const location = url.searchParams.get('location') || 'project';
const projectPathParam = url.searchParams.get('path') || initialPath;
const fileCliTypeParam = url.searchParams.get('cliType');
const fileCliType: CliType = fileCliTypeParam === 'codex' ? 'codex' : 'claude';
const fileCliDir = getCliDir(fileCliType);
if (!fileName) {
res.writeHead(400, { 'Content-Type': 'application/json' });
@@ -1005,7 +1043,7 @@ export async function handleSkillsRoutes(ctx: RouteContext): Promise<boolean> {
if (location === 'project') {
try {
const validatedProjectPath = await validateAllowedPath(projectPathParam, { mustExist: true, allowedDirectories: [initialPath] });
baseDir = join(validatedProjectPath, '.claude', 'skills');
baseDir = join(validatedProjectPath, fileCliDir, 'skills');
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const status = message.includes('Access denied') ? 403 : 400;
@@ -1015,7 +1053,7 @@ export async function handleSkillsRoutes(ctx: RouteContext): Promise<boolean> {
return true;
}
} else {
baseDir = join(homedir(), '.claude', 'skills');
baseDir = join(homedir(), fileCliDir, 'skills');
}
const skillRoot = join(baseDir, skillName);
@@ -1082,12 +1120,16 @@ export async function handleSkillsRoutes(ctx: RouteContext): Promise<boolean> {
return { error: 'Invalid skill name', status: 400 };
}
const writeCliTypeValue = typeof body.cliType === 'string' ? body.cliType : 'claude';
const writeCliType: CliType = writeCliTypeValue === 'codex' ? 'codex' : 'claude';
const writeCliDir = getCliDir(writeCliType);
let baseDir: string;
if (location === 'project') {
try {
const projectRoot = projectPathParam || initialPath;
const validatedProjectPath = await validateAllowedPath(projectRoot, { mustExist: true, allowedDirectories: [initialPath] });
baseDir = join(validatedProjectPath, '.claude', 'skills');
baseDir = join(validatedProjectPath, writeCliDir, 'skills');
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const status = message.includes('Access denied') ? 403 : 400;
@@ -1095,7 +1137,7 @@ export async function handleSkillsRoutes(ctx: RouteContext): Promise<boolean> {
return { error: status === 403 ? 'Access denied' : 'Invalid path', status };
}
} else {
baseDir = join(homedir(), '.claude', 'skills');
baseDir = join(homedir(), writeCliDir, 'skills');
}
const skillRoot = join(baseDir, skillName);
@@ -1128,7 +1170,9 @@ export async function handleSkillsRoutes(ctx: RouteContext): Promise<boolean> {
const locationParam = url.searchParams.get('location');
const location: SkillLocation = locationParam === 'user' ? 'user' : 'project';
const projectPathParam = url.searchParams.get('path') || initialPath;
const skillDetail = await getSkillDetail(skillName, location, projectPathParam, initialPath);
const cliTypeParam = url.searchParams.get('cliType');
const cliType: CliType = cliTypeParam === 'codex' ? 'codex' : 'claude';
const skillDetail = await getSkillDetail(skillName, location, projectPathParam, initialPath, cliType);
if (skillDetail.error) {
res.writeHead(skillDetail.status || 404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: skillDetail.error }));
@@ -1160,8 +1204,10 @@ export async function handleSkillsRoutes(ctx: RouteContext): Promise<boolean> {
const location: SkillLocation = body.location === 'project' ? 'project' : 'user';
const projectPathParam = typeof body.projectPath === 'string' ? body.projectPath : undefined;
const delCliTypeValue = typeof body.cliType === 'string' ? body.cliType : 'claude';
const delCliType: CliType = delCliTypeValue === 'codex' ? 'codex' : 'claude';
return deleteSkill(skillName, location, projectPathParam || initialPath, initialPath);
return deleteSkill(skillName, location, projectPathParam || initialPath, initialPath, delCliType);
});
return true;
}
@@ -1205,6 +1251,8 @@ export async function handleSkillsRoutes(ctx: RouteContext): Promise<boolean> {
const description = typeof body.description === 'string' ? body.description : undefined;
const generationType = typeof body.generationType === 'string' ? body.generationType : undefined;
const projectPathParam = typeof body.projectPath === 'string' ? body.projectPath : undefined;
const createCliTypeValue = typeof body.cliType === 'string' ? body.cliType : 'claude';
const createCliType: CliType = createCliTypeValue === 'codex' ? 'codex' : 'claude';
if (typeof mode !== 'string' || !mode) {
return { error: 'Mode is required (import or cli-generate)' };
@@ -1249,7 +1297,7 @@ export async function handleSkillsRoutes(ctx: RouteContext): Promise<boolean> {
return { error: status === 403 ? 'Access denied' : 'Invalid path', status };
}
return await importSkill(validatedSourcePath, location, validatedProjectPath, skillName);
return await importSkill(validatedSourcePath, location, validatedProjectPath, skillName, createCliType);
} else if (mode === 'cli-generate') {
// CLI generate mode: use Claude to generate skill
if (!skillName) {
@@ -1265,7 +1313,8 @@ export async function handleSkillsRoutes(ctx: RouteContext): Promise<boolean> {
skillName,
location,
projectPath: validatedProjectPath,
broadcastToClients
broadcastToClients,
cliType: createCliType
});
} else {
return { error: 'Invalid mode. Must be "import" or "cli-generate"' };