mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-10 02:24:35 +08:00
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:
@@ -50,6 +50,10 @@
|
|||||||
- Use `git add <specific-files>` instead of `git add .`
|
- Use `git add <specific-files>` instead of `git add .`
|
||||||
- Verify staged files before commit to avoid cross-task conflicts
|
- Verify staged files before commit to avoid cross-task conflicts
|
||||||
|
|
||||||
|
**Multi-CLI Coexistence** (CRITICAL):
|
||||||
|
- If your task conflicts with existing uncommitted changes, **STOP and report the conflict** instead of overwriting
|
||||||
|
- Treat all pre-existing uncommitted changes as intentional work-in-progress by other tools
|
||||||
|
|
||||||
|
|
||||||
## System Optimization
|
## System Optimization
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ export default defineConfig({
|
|||||||
workers: process.env.CI ? 1 : undefined,
|
workers: process.env.CI ? 1 : undefined,
|
||||||
reporter: 'html',
|
reporter: 'html',
|
||||||
use: {
|
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',
|
trace: 'on-first-retry',
|
||||||
},
|
},
|
||||||
projects: [
|
projects: [
|
||||||
@@ -27,7 +29,11 @@ export default defineConfig({
|
|||||||
],
|
],
|
||||||
webServer: {
|
webServer: {
|
||||||
command: 'npm run dev',
|
command: 'npm run dev',
|
||||||
url: 'http://localhost:5173/react/',
|
url: 'http://localhost:5173/',
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
VITE_BASE_URL: '/',
|
||||||
|
},
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
timeout: 120 * 1000,
|
timeout: 120 * 1000,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import {
|
|||||||
MessageCircleQuestion,
|
MessageCircleQuestion,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
Globe,
|
||||||
|
Folder,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Card } from '@/components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
@@ -31,6 +33,7 @@ import {
|
|||||||
import { mcpServersKeys } from '@/hooks';
|
import { mcpServersKeys } from '@/hooks';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||||
|
|
||||||
// ========== Types ==========
|
// ========== Types ==========
|
||||||
|
|
||||||
@@ -103,16 +106,19 @@ export function CcwToolsMcpCard({
|
|||||||
}: CcwToolsMcpCardProps) {
|
}: CcwToolsMcpCardProps) {
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const currentProjectPath = useWorkflowStore(selectProjectPath);
|
||||||
|
|
||||||
// Local state for config inputs
|
// Local state for config inputs
|
||||||
const [projectRootInput, setProjectRootInput] = useState(projectRoot || '');
|
const [projectRootInput, setProjectRootInput] = useState(projectRoot || '');
|
||||||
const [allowedDirsInput, setAllowedDirsInput] = useState(allowedDirs || '');
|
const [allowedDirsInput, setAllowedDirsInput] = useState(allowedDirs || '');
|
||||||
const [disableSandboxInput, setDisableSandboxInput] = useState(disableSandbox || false);
|
const [disableSandboxInput, setDisableSandboxInput] = useState(disableSandbox || false);
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
const [installScope, setInstallScope] = useState<'global' | 'project'>('global');
|
||||||
|
|
||||||
// Mutations for install/uninstall
|
// Mutations for install/uninstall
|
||||||
const installMutation = useMutation({
|
const installMutation = useMutation({
|
||||||
mutationFn: installCcwMcp,
|
mutationFn: (params: { scope: 'global' | 'project'; projectPath?: string }) =>
|
||||||
|
installCcwMcp(params.scope, params.projectPath),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
|
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
|
||||||
queryClient.invalidateQueries({ queryKey: ['ccwMcpConfig'] });
|
queryClient.invalidateQueries({ queryKey: ['ccwMcpConfig'] });
|
||||||
@@ -167,7 +173,10 @@ export function CcwToolsMcpCard({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleInstallClick = () => {
|
const handleInstallClick = () => {
|
||||||
installMutation.mutate();
|
installMutation.mutate({
|
||||||
|
scope: installScope,
|
||||||
|
projectPath: installScope === 'project' ? currentProjectPath : undefined,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUninstallClick = () => {
|
const handleUninstallClick = () => {
|
||||||
@@ -257,7 +266,7 @@ export function CcwToolsMcpCard({
|
|||||||
<p className="text-xs font-medium text-muted-foreground uppercase">
|
<p className="text-xs font-medium text-muted-foreground uppercase">
|
||||||
{formatMessage({ id: 'mcp.ccw.tools.label' })}
|
{formatMessage({ id: 'mcp.ccw.tools.label' })}
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{CCW_MCP_TOOLS.map((tool) => {
|
{CCW_MCP_TOOLS.map((tool) => {
|
||||||
const isEnabled = enabledTools.includes(tool.name);
|
const isEnabled = enabledTools.includes(tool.name);
|
||||||
const icon = getToolIcon(tool.name);
|
const icon = getToolIcon(tool.name);
|
||||||
@@ -266,8 +275,8 @@ export function CcwToolsMcpCard({
|
|||||||
<div
|
<div
|
||||||
key={tool.name}
|
key={tool.name}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-3 p-2 rounded-lg transition-colors',
|
'flex items-center gap-3 p-2 rounded-lg transition-colors border',
|
||||||
isEnabled ? 'bg-background' : 'bg-muted/50'
|
isEnabled ? 'bg-background border-primary/40' : 'bg-background border-border'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -381,7 +390,41 @@ export function CcwToolsMcpCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Install/Uninstall Button */}
|
{/* 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 ? (
|
{!isInstalled ? (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleInstallClick}
|
onClick={handleInstallClick}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export interface SkillCreateDialogProps {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
onCreated: () => void;
|
onCreated: () => void;
|
||||||
|
cliType?: 'claude' | 'codex';
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateMode = 'import' | 'cli-generate';
|
type CreateMode = 'import' | 'cli-generate';
|
||||||
@@ -48,7 +49,7 @@ interface ValidationResult {
|
|||||||
skillInfo?: { name: string; description: string; version?: string; supportingFiles?: string[] };
|
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 { formatMessage } = useIntl();
|
||||||
const projectPath = useWorkflowStore(selectProjectPath);
|
const projectPath = useWorkflowStore(selectProjectPath);
|
||||||
|
|
||||||
@@ -125,6 +126,7 @@ export function SkillCreateDialog({ open, onOpenChange, onCreated }: SkillCreate
|
|||||||
description: mode === 'cli-generate' ? description.trim() : undefined,
|
description: mode === 'cli-generate' ? description.trim() : undefined,
|
||||||
generationType: mode === 'cli-generate' ? 'description' : undefined,
|
generationType: mode === 'cli-generate' ? 'description' : undefined,
|
||||||
projectPath,
|
projectPath,
|
||||||
|
cliType,
|
||||||
});
|
});
|
||||||
|
|
||||||
handleOpenChange(false);
|
handleOpenChange(false);
|
||||||
|
|||||||
@@ -207,6 +207,8 @@ export {
|
|||||||
useRefreshCodexCliEnhancement,
|
useRefreshCodexCliEnhancement,
|
||||||
useCcwInstallStatus,
|
useCcwInstallStatus,
|
||||||
useCliToolStatus,
|
useCliToolStatus,
|
||||||
|
useCcwInstallations,
|
||||||
|
useUpgradeCcwInstallation,
|
||||||
systemSettingsKeys,
|
systemSettingsKeys,
|
||||||
} from './useSystemSettings';
|
} from './useSystemSettings';
|
||||||
export type {
|
export type {
|
||||||
@@ -215,6 +217,7 @@ export type {
|
|||||||
UseCodexCliEnhancementStatusReturn,
|
UseCodexCliEnhancementStatusReturn,
|
||||||
UseCcwInstallStatusReturn,
|
UseCcwInstallStatusReturn,
|
||||||
UseCliToolStatusReturn,
|
UseCliToolStatusReturn,
|
||||||
|
UseCcwInstallationsReturn,
|
||||||
} from './useSystemSettings';
|
} from './useSystemSettings';
|
||||||
|
|
||||||
// ========== CLI Execution ==========
|
// ========== CLI Execution ==========
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export interface UseSkillsOptions {
|
|||||||
filter?: SkillsFilter;
|
filter?: SkillsFilter;
|
||||||
staleTime?: number;
|
staleTime?: number;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
cliType?: 'claude' | 'codex';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UseSkillsReturn {
|
export interface UseSkillsReturn {
|
||||||
@@ -61,15 +62,19 @@ export interface UseSkillsReturn {
|
|||||||
* Hook for fetching and filtering skills
|
* Hook for fetching and filtering skills
|
||||||
*/
|
*/
|
||||||
export function useSkills(options: UseSkillsOptions = {}): UseSkillsReturn {
|
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 queryClient = useQueryClient();
|
||||||
const projectPath = useWorkflowStore(selectProjectPath);
|
const projectPath = useWorkflowStore(selectProjectPath);
|
||||||
|
|
||||||
|
const queryKey = cliType === 'codex'
|
||||||
|
? workspaceQueryKeys.codexSkillsList(projectPath)
|
||||||
|
: workspaceQueryKeys.skillsList(projectPath);
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: workspaceQueryKeys.skillsList(projectPath),
|
queryKey,
|
||||||
queryFn: () => fetchSkills(projectPath),
|
queryFn: () => fetchSkills(projectPath, cliType),
|
||||||
staleTime,
|
staleTime,
|
||||||
enabled: enabled, // Remove projectPath requirement - API works without it
|
enabled: enabled,
|
||||||
retry: 2,
|
retry: 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -133,7 +138,10 @@ export function useSkills(options: UseSkillsOptions = {}): UseSkillsReturn {
|
|||||||
|
|
||||||
const invalidate = async () => {
|
const invalidate = async () => {
|
||||||
if (projectPath) {
|
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 ==========
|
// ========== Mutations ==========
|
||||||
|
|
||||||
export interface UseToggleSkillReturn {
|
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;
|
isToggling: boolean;
|
||||||
error: Error | null;
|
error: Error | null;
|
||||||
}
|
}
|
||||||
@@ -168,10 +176,10 @@ export function useToggleSkill(): UseToggleSkillReturn {
|
|||||||
const { addToast, removeToast, success, error } = useNotifications();
|
const { addToast, removeToast, success, error } = useNotifications();
|
||||||
|
|
||||||
const mutation = useMutation({
|
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
|
enabled
|
||||||
? enableSkill(skillName, location, projectPath)
|
? enableSkill(skillName, location, projectPath, cliType)
|
||||||
: disableSkill(skillName, location, projectPath),
|
: disableSkill(skillName, location, projectPath, cliType),
|
||||||
onMutate: (): { loadingId: string } => {
|
onMutate: (): { loadingId: string } => {
|
||||||
const loadingId = addToast('info', formatMessage('common.loading'), undefined, { duration: 0 });
|
const loadingId = addToast('info', formatMessage('common.loading'), undefined, { duration: 0 });
|
||||||
return { loadingId };
|
return { loadingId };
|
||||||
@@ -183,7 +191,13 @@ export function useToggleSkill(): UseToggleSkillReturn {
|
|||||||
const operation = variables.enabled ? 'skillEnable' : 'skillDisable';
|
const operation = variables.enabled ? 'skillEnable' : 'skillDisable';
|
||||||
success(formatMessage(`feedback.${operation}.success`));
|
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) => {
|
onError: (err, variables, context) => {
|
||||||
const { loadingId } = context ?? { loadingId: '' };
|
const { loadingId } = context ?? { loadingId: '' };
|
||||||
@@ -196,7 +210,7 @@ export function useToggleSkill(): UseToggleSkillReturn {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
toggleSkill: (skillName, enabled, location) => mutation.mutateAsync({ skillName, enabled, location }),
|
toggleSkill: (skillName, enabled, location, cliType) => mutation.mutateAsync({ skillName, enabled, location, cliType }),
|
||||||
isToggling: mutation.isPending,
|
isToggling: mutation.isPending,
|
||||||
error: mutation.error,
|
error: mutation.error,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,10 +14,13 @@ import {
|
|||||||
refreshCodexCliEnhancement,
|
refreshCodexCliEnhancement,
|
||||||
fetchAggregatedStatus,
|
fetchAggregatedStatus,
|
||||||
fetchCliToolStatus,
|
fetchCliToolStatus,
|
||||||
|
fetchCcwInstallations,
|
||||||
|
upgradeCcwInstallation,
|
||||||
type ChineseResponseStatus,
|
type ChineseResponseStatus,
|
||||||
type WindowsPlatformStatus,
|
type WindowsPlatformStatus,
|
||||||
type CodexCliEnhancementStatus,
|
type CodexCliEnhancementStatus,
|
||||||
type CcwInstallStatus,
|
type CcwInstallStatus,
|
||||||
|
type CcwInstallationManifest,
|
||||||
} from '../lib/api';
|
} from '../lib/api';
|
||||||
|
|
||||||
// Query key factory
|
// Query key factory
|
||||||
@@ -28,6 +31,7 @@ export const systemSettingsKeys = {
|
|||||||
codexCliEnhancement: () => [...systemSettingsKeys.all, 'codexCliEnhancement'] as const,
|
codexCliEnhancement: () => [...systemSettingsKeys.all, 'codexCliEnhancement'] as const,
|
||||||
aggregatedStatus: () => [...systemSettingsKeys.all, 'aggregatedStatus'] as const,
|
aggregatedStatus: () => [...systemSettingsKeys.all, 'aggregatedStatus'] as const,
|
||||||
cliToolStatus: () => [...systemSettingsKeys.all, 'cliToolStatus'] as const,
|
cliToolStatus: () => [...systemSettingsKeys.all, 'cliToolStatus'] as const,
|
||||||
|
ccwInstallations: () => [...systemSettingsKeys.all, 'ccwInstallations'] as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
const STALE_TIME = 60 * 1000; // 1 minute
|
const STALE_TIME = 60 * 1000; // 1 minute
|
||||||
@@ -236,3 +240,48 @@ export function useCliToolStatus(): UseCliToolStatusReturn {
|
|||||||
refetch: () => { query.refetch(); },
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -992,7 +992,7 @@ export interface SkillsResponse {
|
|||||||
* Fetch all skills for a specific workspace
|
* Fetch all skills for a specific workspace
|
||||||
* @param projectPath - Optional project path to filter data by 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
|
// Response type from backend when includeDisabled=true
|
||||||
interface ExtendedSkillsResponse {
|
interface ExtendedSkillsResponse {
|
||||||
skills?: Skill[];
|
skills?: Skill[];
|
||||||
@@ -1017,10 +1017,12 @@ export async function fetchSkills(projectPath?: string): Promise<SkillsResponse>
|
|||||||
return [...projectSkillsEnabled, ...userSkillsEnabled, ...projectSkillsDisabled, ...userSkillsDisabled];
|
return [...projectSkillsEnabled, ...userSkillsEnabled, ...projectSkillsDisabled, ...userSkillsDisabled];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const cliTypeParam = cliType === 'codex' ? '&cliType=codex' : '';
|
||||||
|
|
||||||
// Try with project path first, fall back to global on 403/404
|
// Try with project path first, fall back to global on 403/404
|
||||||
if (projectPath) {
|
if (projectPath) {
|
||||||
try {
|
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);
|
const data = await fetchApi<ExtendedSkillsResponse>(url);
|
||||||
return { skills: buildSkillsList(data) };
|
return { skills: buildSkillsList(data) };
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
@@ -1034,7 +1036,7 @@ export async function fetchSkills(projectPath?: string): Promise<SkillsResponse>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Fallback: fetch global skills
|
// 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) };
|
return { skills: buildSkillsList(data) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1044,11 +1046,12 @@ export async function fetchSkills(projectPath?: string): Promise<SkillsResponse>
|
|||||||
export async function enableSkill(
|
export async function enableSkill(
|
||||||
skillName: string,
|
skillName: string,
|
||||||
location: 'project' | 'user',
|
location: 'project' | 'user',
|
||||||
projectPath?: string
|
projectPath?: string,
|
||||||
|
cliType: 'claude' | 'codex' = 'claude'
|
||||||
): Promise<Skill> {
|
): Promise<Skill> {
|
||||||
return fetchApi<Skill>(`/api/skills/${encodeURIComponent(skillName)}/enable`, {
|
return fetchApi<Skill>(`/api/skills/${encodeURIComponent(skillName)}/enable`, {
|
||||||
method: 'POST',
|
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(
|
export async function disableSkill(
|
||||||
skillName: string,
|
skillName: string,
|
||||||
location: 'project' | 'user',
|
location: 'project' | 'user',
|
||||||
projectPath?: string
|
projectPath?: string,
|
||||||
|
cliType: 'claude' | 'codex' = 'claude'
|
||||||
): Promise<Skill> {
|
): Promise<Skill> {
|
||||||
return fetchApi<Skill>(`/api/skills/${encodeURIComponent(skillName)}/disable`, {
|
return fetchApi<Skill>(`/api/skills/${encodeURIComponent(skillName)}/disable`, {
|
||||||
method: 'POST',
|
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(
|
export async function fetchSkillDetail(
|
||||||
skillName: string,
|
skillName: string,
|
||||||
location: 'project' | 'user',
|
location: 'project' | 'user',
|
||||||
projectPath?: string
|
projectPath?: string,
|
||||||
|
cliType: 'claude' | 'codex' = 'claude'
|
||||||
): Promise<{ skill: Skill }> {
|
): Promise<{ skill: Skill }> {
|
||||||
|
const cliTypeParam = cliType === 'codex' ? '&cliType=codex' : '';
|
||||||
const url = projectPath
|
const url = projectPath
|
||||||
? `/api/skills/${encodeURIComponent(skillName)}?location=${location}&path=${encodeURIComponent(projectPath)}`
|
? `/api/skills/${encodeURIComponent(skillName)}?location=${location}&path=${encodeURIComponent(projectPath)}${cliTypeParam}`
|
||||||
: `/api/skills/${encodeURIComponent(skillName)}?location=${location}`;
|
: `/api/skills/${encodeURIComponent(skillName)}?location=${location}${cliTypeParam}`;
|
||||||
return fetchApi<{ skill: Skill }>(url);
|
return fetchApi<{ skill: Skill }>(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1108,6 +1114,7 @@ export async function createSkill(params: {
|
|||||||
description?: string;
|
description?: string;
|
||||||
generationType?: 'description' | 'template';
|
generationType?: 'description' | 'template';
|
||||||
projectPath?: string;
|
projectPath?: string;
|
||||||
|
cliType?: 'claude' | 'codex';
|
||||||
}): Promise<{ skillName: string; path: string }> {
|
}): Promise<{ skillName: string; path: string }> {
|
||||||
return fetchApi('/api/skills/create', {
|
return fetchApi('/api/skills/create', {
|
||||||
method: 'POST',
|
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
|
* Update hook using dedicated update endpoint with partial input
|
||||||
*/
|
*/
|
||||||
@@ -3325,14 +3347,32 @@ export async function updateCcwConfig(config: {
|
|||||||
/**
|
/**
|
||||||
* Install CCW Tools MCP server
|
* 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({
|
const serverConfig = buildCcwMcpServerConfig({
|
||||||
enabledTools: ['write_file', 'edit_file', 'read_file', 'core_memory', 'ask_question'],
|
enabledTools: ['write_file', 'edit_file', 'read_file', 'core_memory', 'ask_question'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await addGlobalMcpServer('ccw-tools', serverConfig);
|
if (scope === 'project' && projectPath) {
|
||||||
if (!result.success) {
|
const result = await fetchApi<{ success?: boolean; error?: string }>('/api/mcp-copy-server', {
|
||||||
throw new Error(result.error || 'Failed to install CCW MCP');
|
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();
|
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 }>> {
|
export async function fetchCliToolStatus(): Promise<Record<string, { available: boolean; path?: string; version?: string }>> {
|
||||||
return fetchApi('/api/cli/status');
|
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 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ export const workspaceQueryKeys = {
|
|||||||
// ========== Skills ==========
|
// ========== Skills ==========
|
||||||
skills: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'skills'] as const,
|
skills: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'skills'] as const,
|
||||||
skillsList: (projectPath: string) => [...workspaceQueryKeys.skills(projectPath), 'list'] 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 ==========
|
||||||
commands: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'commands'] as const,
|
commands: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'commands'] as const,
|
||||||
|
|||||||
@@ -67,7 +67,17 @@
|
|||||||
"disabled": "Disabled"
|
"disabled": "Disabled"
|
||||||
},
|
},
|
||||||
"systemStatus": {
|
"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",
|
"ccwInstall": "CCW Installation",
|
||||||
"installed": "Installed",
|
"installed": "Installed",
|
||||||
"incomplete": "Incomplete",
|
"incomplete": "Incomplete",
|
||||||
|
|||||||
@@ -67,7 +67,17 @@
|
|||||||
"disabled": "已禁用"
|
"disabled": "已禁用"
|
||||||
},
|
},
|
||||||
"systemStatus": {
|
"systemStatus": {
|
||||||
"title": "系统状态",
|
"title": "CCW 安装",
|
||||||
|
"installations": "个安装",
|
||||||
|
"noInstallations": "未检测到安装",
|
||||||
|
"installPrompt": "运行 ccw install 进行安装",
|
||||||
|
"global": "Global",
|
||||||
|
"path": "Path",
|
||||||
|
"version": "版本",
|
||||||
|
"files": "files",
|
||||||
|
"upgrade": "升级",
|
||||||
|
"upgrading": "升级中...",
|
||||||
|
"refresh": "刷新",
|
||||||
"ccwInstall": "CCW 安装状态",
|
"ccwInstall": "CCW 安装状态",
|
||||||
"installed": "已安装",
|
"installed": "已安装",
|
||||||
"incomplete": "不完整",
|
"incomplete": "不完整",
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ import {
|
|||||||
Monitor,
|
Monitor,
|
||||||
Terminal,
|
Terminal,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
Package,
|
||||||
|
Home,
|
||||||
|
Folder,
|
||||||
|
Calendar,
|
||||||
|
File,
|
||||||
|
ArrowUpCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Card } from '@/components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
@@ -43,6 +49,8 @@ import {
|
|||||||
useRefreshCodexCliEnhancement,
|
useRefreshCodexCliEnhancement,
|
||||||
useCcwInstallStatus,
|
useCcwInstallStatus,
|
||||||
useCliToolStatus,
|
useCliToolStatus,
|
||||||
|
useCcwInstallations,
|
||||||
|
useUpgradeCcwInstallation,
|
||||||
} from '@/hooks/useSystemSettings';
|
} from '@/hooks/useSystemSettings';
|
||||||
|
|
||||||
// ========== CLI Tool Card Component ==========
|
// ========== CLI Tool Card Component ==========
|
||||||
@@ -517,51 +525,137 @@ function ResponseLanguageSection() {
|
|||||||
|
|
||||||
function SystemStatusSection() {
|
function SystemStatusSection() {
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
const { data: ccwInstall, isLoading: installLoading } = useCcwInstallStatus();
|
const { installations, isLoading, refetch } = useCcwInstallations();
|
||||||
|
const { upgrade, isPending: upgrading } = useUpgradeCcwInstallation();
|
||||||
// Don't show if installed or still loading
|
const { data: ccwInstall } = useCcwInstallStatus();
|
||||||
if (installLoading || ccwInstall?.installed) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="p-6 border-yellow-500/50">
|
<Card className="p-6">
|
||||||
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2 mb-4">
|
{/* Header */}
|
||||||
<AlertTriangle className="w-5 h-5 text-yellow-500" />
|
<div className="flex items-center justify-between mb-4">
|
||||||
{formatMessage({ id: 'settings.systemStatus.title' })}
|
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2">
|
||||||
</h2>
|
<Package className="w-5 h-5" />
|
||||||
|
{formatMessage({ id: 'settings.systemStatus.title' })}
|
||||||
{/* CCW Install Status */}
|
{!isLoading && (
|
||||||
<div className="space-y-3">
|
<span className="text-sm font-normal text-muted-foreground">
|
||||||
<div className="flex items-center justify-between">
|
{installations.length} {formatMessage({ id: 'settings.systemStatus.installations' })}
|
||||||
<span className="text-sm font-medium">{formatMessage({ id: 'settings.systemStatus.ccwInstall' })}</span>
|
</span>
|
||||||
<Badge variant="outline" className="text-yellow-500 border-yellow-500/50">
|
)}
|
||||||
{formatMessage({ id: 'settings.systemStatus.incomplete' })}
|
</h2>
|
||||||
</Badge>
|
<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>
|
</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>
|
</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' })} — {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>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -865,31 +959,6 @@ export function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</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 */}
|
{/* Reset Settings */}
|
||||||
<Card className="p-6 border-destructive/50">
|
<Card className="p-6 border-destructive/50">
|
||||||
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2 mb-4">
|
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2 mb-4">
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
AlertDialogCancel,
|
AlertDialogCancel,
|
||||||
} from '@/components/ui';
|
} from '@/components/ui';
|
||||||
import { SkillCard, SkillDetailPanel, SkillCreateDialog } from '@/components/shared';
|
import { SkillCard, SkillDetailPanel, SkillCreateDialog } from '@/components/shared';
|
||||||
|
import { CliModeToggle, type CliMode } from '@/components/mcp/CliModeToggle';
|
||||||
import { useSkills, useSkillMutations } from '@/hooks';
|
import { useSkills, useSkillMutations } from '@/hooks';
|
||||||
import { fetchSkillDetail } from '@/lib/api';
|
import { fetchSkillDetail } from '@/lib/api';
|
||||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||||
@@ -109,6 +110,7 @@ export function SkillsManagerPage() {
|
|||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
const projectPath = useWorkflowStore(selectProjectPath);
|
const projectPath = useWorkflowStore(selectProjectPath);
|
||||||
|
|
||||||
|
const [cliMode, setCliMode] = useState<CliMode>('claude');
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [categoryFilter, setCategoryFilter] = useState<string>('all');
|
const [categoryFilter, setCategoryFilter] = useState<string>('all');
|
||||||
const [sourceFilter, setSourceFilter] = useState<string>('all');
|
const [sourceFilter, setSourceFilter] = useState<string>('all');
|
||||||
@@ -143,6 +145,7 @@ export function SkillsManagerPage() {
|
|||||||
enabledOnly: enabledFilter === 'enabled',
|
enabledOnly: enabledFilter === 'enabled',
|
||||||
location: locationFilter,
|
location: locationFilter,
|
||||||
},
|
},
|
||||||
|
cliType: cliMode,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { toggleSkill, isToggling } = useSkillMutations();
|
const { toggleSkill, isToggling } = useSkillMutations();
|
||||||
@@ -165,18 +168,19 @@ export function SkillsManagerPage() {
|
|||||||
const location = skill.location || 'project';
|
const location = skill.location || 'project';
|
||||||
// Use folderName for API calls (actual folder name), fallback to name if not available
|
// Use folderName for API calls (actual folder name), fallback to name if not available
|
||||||
const skillIdentifier = skill.folderName || skill.name;
|
const skillIdentifier = skill.folderName || skill.name;
|
||||||
|
|
||||||
// Debug logging
|
// Debug logging
|
||||||
console.log('[SkillToggle] Toggling skill:', {
|
console.log('[SkillToggle] Toggling skill:', {
|
||||||
name: skill.name,
|
name: skill.name,
|
||||||
folderName: skill.folderName,
|
folderName: skill.folderName,
|
||||||
location,
|
location,
|
||||||
enabled,
|
enabled,
|
||||||
skillIdentifier
|
skillIdentifier,
|
||||||
|
cliMode
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await toggleSkill(skillIdentifier, enabled, location);
|
await toggleSkill(skillIdentifier, enabled, location, cliMode);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SkillToggle] Toggle failed:', error);
|
console.error('[SkillToggle] Toggle failed:', error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -211,7 +215,8 @@ export function SkillsManagerPage() {
|
|||||||
const data = await fetchSkillDetail(
|
const data = await fetchSkillDetail(
|
||||||
skill.name,
|
skill.name,
|
||||||
skill.location || 'project',
|
skill.location || 'project',
|
||||||
projectPath
|
projectPath,
|
||||||
|
cliMode
|
||||||
);
|
);
|
||||||
setSelectedSkill(data.skill);
|
setSelectedSkill(data.skill);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -220,7 +225,7 @@ export function SkillsManagerPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsDetailLoading(false);
|
setIsDetailLoading(false);
|
||||||
}
|
}
|
||||||
}, [projectPath]);
|
}, [projectPath, cliMode]);
|
||||||
|
|
||||||
const handleCloseDetailPanel = useCallback(() => {
|
const handleCloseDetailPanel = useCallback(() => {
|
||||||
setIsDetailPanelOpen(false);
|
setIsDetailPanelOpen(false);
|
||||||
@@ -232,14 +237,23 @@ export function SkillsManagerPage() {
|
|||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
<div>
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
|
<div>
|
||||||
<Sparkles className="w-6 h-6 text-primary" />
|
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
|
||||||
{formatMessage({ id: 'skills.title' })}
|
<Sparkles className="w-6 h-6 text-primary" />
|
||||||
</h1>
|
{formatMessage({ id: 'skills.title' })}
|
||||||
<p className="text-muted-foreground mt-1">
|
</h1>
|
||||||
{formatMessage({ id: 'skills.description' })}
|
<p className="text-muted-foreground mt-1">
|
||||||
</p>
|
{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>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="outline" onClick={() => refetch()} disabled={isFetching}>
|
<Button variant="outline" onClick={() => refetch()} disabled={isFetching}>
|
||||||
@@ -479,6 +493,7 @@ export function SkillsManagerPage() {
|
|||||||
open={isCreateDialogOpen}
|
open={isCreateDialogOpen}
|
||||||
onOpenChange={setIsCreateDialogOpen}
|
onOpenChange={setIsCreateDialogOpen}
|
||||||
onCreated={() => refetch()}
|
onCreated={() => refetch()}
|
||||||
|
cliType={cliMode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ import type {
|
|||||||
} from '../../types/skill-types.js';
|
} from '../../types/skill-types.js';
|
||||||
|
|
||||||
type GenerationType = 'description' | 'template';
|
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 {
|
interface GenerationParams {
|
||||||
generationType: GenerationType;
|
generationType: GenerationType;
|
||||||
@@ -30,6 +36,7 @@ interface GenerationParams {
|
|||||||
location: SkillLocation;
|
location: SkillLocation;
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
broadcastToClients?: (data: unknown) => void;
|
broadcastToClients?: (data: unknown) => void;
|
||||||
|
cliType?: CliType;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
@@ -46,7 +53,8 @@ async function disableSkill(
|
|||||||
location: SkillLocation,
|
location: SkillLocation,
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
initialPath: 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> {
|
): Promise<SkillOperationResult> {
|
||||||
try {
|
try {
|
||||||
// Validate skill name
|
// Validate skill name
|
||||||
@@ -54,18 +62,20 @@ async function disableSkill(
|
|||||||
return { success: false, message: 'Invalid skill name', status: 400 };
|
return { success: false, message: 'Invalid skill name', status: 400 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cliDir = getCliDir(cliType);
|
||||||
|
|
||||||
// Get skill directory
|
// Get skill directory
|
||||||
let skillsDir: string;
|
let skillsDir: string;
|
||||||
if (location === 'project') {
|
if (location === 'project') {
|
||||||
try {
|
try {
|
||||||
const validatedProjectPath = await validateAllowedPath(projectPath, { mustExist: true, allowedDirectories: [initialPath] });
|
const validatedProjectPath = await validateAllowedPath(projectPath, { mustExist: true, allowedDirectories: [initialPath] });
|
||||||
skillsDir = join(validatedProjectPath, '.claude', 'skills');
|
skillsDir = join(validatedProjectPath, cliDir, 'skills');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
return { success: false, message: message.includes('Access denied') ? 'Access denied' : 'Invalid path', status: 403 };
|
return { success: false, message: message.includes('Access denied') ? 'Access denied' : 'Invalid path', status: 403 };
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
skillsDir = join(homedir(), '.claude', 'skills');
|
skillsDir = join(homedir(), cliDir, 'skills');
|
||||||
}
|
}
|
||||||
|
|
||||||
const skillDir = join(skillsDir, skillName);
|
const skillDir = join(skillsDir, skillName);
|
||||||
@@ -99,7 +109,8 @@ async function enableSkill(
|
|||||||
skillName: string,
|
skillName: string,
|
||||||
location: SkillLocation,
|
location: SkillLocation,
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
initialPath: string
|
initialPath: string,
|
||||||
|
cliType: CliType = 'claude'
|
||||||
): Promise<SkillOperationResult> {
|
): Promise<SkillOperationResult> {
|
||||||
try {
|
try {
|
||||||
// Validate skill name
|
// Validate skill name
|
||||||
@@ -107,18 +118,20 @@ async function enableSkill(
|
|||||||
return { success: false, message: 'Invalid skill name', status: 400 };
|
return { success: false, message: 'Invalid skill name', status: 400 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cliDir = getCliDir(cliType);
|
||||||
|
|
||||||
// Get skill directory
|
// Get skill directory
|
||||||
let skillsDir: string;
|
let skillsDir: string;
|
||||||
if (location === 'project') {
|
if (location === 'project') {
|
||||||
try {
|
try {
|
||||||
const validatedProjectPath = await validateAllowedPath(projectPath, { mustExist: true, allowedDirectories: [initialPath] });
|
const validatedProjectPath = await validateAllowedPath(projectPath, { mustExist: true, allowedDirectories: [initialPath] });
|
||||||
skillsDir = join(validatedProjectPath, '.claude', 'skills');
|
skillsDir = join(validatedProjectPath, cliDir, 'skills');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
return { success: false, message: message.includes('Access denied') ? 'Access denied' : 'Invalid path', status: 403 };
|
return { success: false, message: message.includes('Access denied') ? 'Access denied' : 'Invalid path', status: 403 };
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
skillsDir = join(homedir(), '.claude', 'skills');
|
skillsDir = join(homedir(), cliDir, 'skills');
|
||||||
}
|
}
|
||||||
|
|
||||||
const skillDir = join(skillsDir, skillName);
|
const skillDir = join(skillsDir, skillName);
|
||||||
@@ -148,15 +161,17 @@ async function enableSkill(
|
|||||||
/**
|
/**
|
||||||
* Get list of disabled skills by checking for SKILL.md.disabled files
|
* 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 result: DisabledSkillSummary[] = [];
|
||||||
|
|
||||||
|
const cliDir = getCliDir(cliType);
|
||||||
|
|
||||||
// Get skills directory (not a separate disabled directory)
|
// Get skills directory (not a separate disabled directory)
|
||||||
let skillsDir: string;
|
let skillsDir: string;
|
||||||
if (location === 'project') {
|
if (location === 'project') {
|
||||||
skillsDir = join(projectPath, '.claude', 'skills');
|
skillsDir = join(projectPath, cliDir, 'skills');
|
||||||
} else {
|
} else {
|
||||||
skillsDir = join(homedir(), '.claude', 'skills');
|
skillsDir = join(homedir(), cliDir, 'skills');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!existsSync(skillsDir)) {
|
if (!existsSync(skillsDir)) {
|
||||||
@@ -199,12 +214,12 @@ function getDisabledSkillsList(location: SkillLocation, projectPath: string): Di
|
|||||||
/**
|
/**
|
||||||
* Get extended skills config including disabled skills
|
* Get extended skills config including disabled skills
|
||||||
*/
|
*/
|
||||||
function getExtendedSkillsConfig(projectPath: string): ExtendedSkillsConfig {
|
function getExtendedSkillsConfig(projectPath: string, cliType: CliType = 'claude'): ExtendedSkillsConfig {
|
||||||
const baseConfig = getSkillsConfig(projectPath);
|
const baseConfig = getSkillsConfig(projectPath, cliType);
|
||||||
return {
|
return {
|
||||||
...baseConfig,
|
...baseConfig,
|
||||||
disabledProjectSkills: getDisabledSkillsList('project', projectPath),
|
disabledProjectSkills: getDisabledSkillsList('project', projectPath, cliType),
|
||||||
disabledUserSkills: getDisabledSkillsList('user', projectPath)
|
disabledUserSkills: getDisabledSkillsList('user', projectPath, cliType)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,15 +308,17 @@ function getSupportingFiles(skillDir: string): string[] {
|
|||||||
* @param {string} projectPath
|
* @param {string} projectPath
|
||||||
* @returns {Object}
|
* @returns {Object}
|
||||||
*/
|
*/
|
||||||
function getSkillsConfig(projectPath: string): SkillsConfig {
|
function getSkillsConfig(projectPath: string, cliType: CliType = 'claude'): SkillsConfig {
|
||||||
const result: SkillsConfig = {
|
const result: SkillsConfig = {
|
||||||
projectSkills: [],
|
projectSkills: [],
|
||||||
userSkills: []
|
userSkills: []
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const cliDir = getCliDir(cliType);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Project skills: .claude/skills/
|
// Project skills: .<cliType>/skills/
|
||||||
const projectSkillsDir = join(projectPath, '.claude', 'skills');
|
const projectSkillsDir = join(projectPath, cliDir, 'skills');
|
||||||
if (existsSync(projectSkillsDir)) {
|
if (existsSync(projectSkillsDir)) {
|
||||||
const skills = readdirSync(projectSkillsDir, { withFileTypes: true });
|
const skills = readdirSync(projectSkillsDir, { withFileTypes: true });
|
||||||
for (const skill of skills) {
|
for (const skill of skills) {
|
||||||
@@ -330,8 +347,8 @@ function getSkillsConfig(projectPath: string): SkillsConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// User skills: ~/.claude/skills/
|
// User skills: ~/.<cliType>/skills/
|
||||||
const userSkillsDir = join(homedir(), '.claude', 'skills');
|
const userSkillsDir = join(homedir(), cliDir, 'skills');
|
||||||
if (existsSync(userSkillsDir)) {
|
if (existsSync(userSkillsDir)) {
|
||||||
const skills = readdirSync(userSkillsDir, { withFileTypes: true });
|
const skills = readdirSync(userSkillsDir, { withFileTypes: true });
|
||||||
for (const skill of skills) {
|
for (const skill of skills) {
|
||||||
@@ -373,7 +390,7 @@ function getSkillsConfig(projectPath: string): SkillsConfig {
|
|||||||
* @param {string} projectPath
|
* @param {string} projectPath
|
||||||
* @returns {Object}
|
* @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 {
|
try {
|
||||||
if (skillName.includes('/') || skillName.includes('\\')) {
|
if (skillName.includes('/') || skillName.includes('\\')) {
|
||||||
return { error: 'Access denied', status: 403 };
|
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 };
|
return { error: 'Invalid skill name', status: 400 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cliDir = getCliDir(cliType);
|
||||||
|
|
||||||
let baseDir;
|
let baseDir;
|
||||||
if (location === 'project') {
|
if (location === 'project') {
|
||||||
try {
|
try {
|
||||||
const validatedProjectPath = await validateAllowedPath(projectPath, { mustExist: true, allowedDirectories: [initialPath] });
|
const validatedProjectPath = await validateAllowedPath(projectPath, { mustExist: true, allowedDirectories: [initialPath] });
|
||||||
baseDir = join(validatedProjectPath, '.claude', 'skills');
|
baseDir = join(validatedProjectPath, cliDir, 'skills');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
const status = message.includes('Access denied') ? 403 : 400;
|
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 };
|
return { error: status === 403 ? 'Access denied' : 'Invalid path', status };
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
baseDir = join(homedir(), '.claude', 'skills');
|
baseDir = join(homedir(), cliDir, 'skills');
|
||||||
}
|
}
|
||||||
|
|
||||||
const skillDir = join(baseDir, skillName);
|
const skillDir = join(baseDir, skillName);
|
||||||
@@ -442,7 +461,7 @@ async function getSkillDetail(skillName: string, location: SkillLocation, projec
|
|||||||
* @param {string} projectPath
|
* @param {string} projectPath
|
||||||
* @returns {Object}
|
* @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 {
|
try {
|
||||||
if (skillName.includes('/') || skillName.includes('\\')) {
|
if (skillName.includes('/') || skillName.includes('\\')) {
|
||||||
return { error: 'Access denied', status: 403 };
|
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 };
|
return { error: 'Invalid skill name', status: 400 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cliDir = getCliDir(cliType);
|
||||||
|
|
||||||
let baseDir;
|
let baseDir;
|
||||||
if (location === 'project') {
|
if (location === 'project') {
|
||||||
try {
|
try {
|
||||||
const validatedProjectPath = await validateAllowedPath(projectPath, { mustExist: true, allowedDirectories: [initialPath] });
|
const validatedProjectPath = await validateAllowedPath(projectPath, { mustExist: true, allowedDirectories: [initialPath] });
|
||||||
baseDir = join(validatedProjectPath, '.claude', 'skills');
|
baseDir = join(validatedProjectPath, cliDir, 'skills');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
const status = message.includes('Access denied') ? 403 : 400;
|
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 };
|
return { error: status === 403 ? 'Access denied' : 'Invalid path', status };
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
baseDir = join(homedir(), '.claude', 'skills');
|
baseDir = join(homedir(), cliDir, 'skills');
|
||||||
}
|
}
|
||||||
|
|
||||||
const skillDirCandidate = join(baseDir, skillName);
|
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
|
* @param {string} customName - Optional custom name for skill
|
||||||
* @returns {Object}
|
* @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 {
|
try {
|
||||||
// Validate source folder
|
// Validate source folder
|
||||||
const validation = validateSkillFolder(sourcePath);
|
const validation = validateSkillFolder(sourcePath);
|
||||||
@@ -593,9 +614,10 @@ async function importSkill(sourcePath: string, location: SkillLocation, projectP
|
|||||||
return { error: validation.errors.join(', ') };
|
return { error: validation.errors.join(', ') };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cliDir = getCliDir(cliType);
|
||||||
const baseDir = location === 'project'
|
const baseDir = location === 'project'
|
||||||
? join(projectPath, '.claude', 'skills')
|
? join(projectPath, cliDir, 'skills')
|
||||||
: join(homedir(), '.claude', 'skills');
|
: join(homedir(), cliDir, 'skills');
|
||||||
|
|
||||||
// Ensure base directory exists
|
// Ensure base directory exists
|
||||||
if (!existsSync(baseDir)) {
|
if (!existsSync(baseDir)) {
|
||||||
@@ -639,7 +661,7 @@ async function importSkill(sourcePath: string, location: SkillLocation, projectP
|
|||||||
* @param {Function} params.broadcastToClients - WebSocket broadcast function
|
* @param {Function} params.broadcastToClients - WebSocket broadcast function
|
||||||
* @returns {Object}
|
* @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
|
// Generate unique execution ID for tracking
|
||||||
const executionId = `skill-gen-${skillName}-${Date.now()}`;
|
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' };
|
return { error: 'Description is required for description-based generation' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cliDir = getCliDir(cliType);
|
||||||
|
|
||||||
// Determine target directory
|
// Determine target directory
|
||||||
const baseDir = location === 'project'
|
const baseDir = location === 'project'
|
||||||
? join(projectPath, '.claude', 'skills')
|
? join(projectPath, cliDir, 'skills')
|
||||||
: join(homedir(), '.claude', 'skills');
|
: join(homedir(), cliDir, 'skills');
|
||||||
|
|
||||||
const targetPath = join(baseDir, skillName);
|
const targetPath = join(baseDir, skillName);
|
||||||
|
|
||||||
@@ -810,16 +834,18 @@ export async function handleSkillsRoutes(ctx: RouteContext): Promise<boolean> {
|
|||||||
if (pathname === '/api/skills' && req.method === 'GET') {
|
if (pathname === '/api/skills' && req.method === 'GET') {
|
||||||
const projectPathParam = url.searchParams.get('path') || initialPath;
|
const projectPathParam = url.searchParams.get('path') || initialPath;
|
||||||
const includeDisabled = url.searchParams.get('includeDisabled') === 'true';
|
const includeDisabled = url.searchParams.get('includeDisabled') === 'true';
|
||||||
|
const cliTypeParam = url.searchParams.get('cliType');
|
||||||
|
const cliType: CliType = cliTypeParam === 'codex' ? 'codex' : 'claude';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const validatedProjectPath = await validateAllowedPath(projectPathParam, { mustExist: true, allowedDirectories: [initialPath] });
|
const validatedProjectPath = await validateAllowedPath(projectPathParam, { mustExist: true, allowedDirectories: [initialPath] });
|
||||||
|
|
||||||
if (includeDisabled) {
|
if (includeDisabled) {
|
||||||
const extendedData = getExtendedSkillsConfig(validatedProjectPath);
|
const extendedData = getExtendedSkillsConfig(validatedProjectPath, cliType);
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify(extendedData));
|
res.end(JSON.stringify(extendedData));
|
||||||
} else {
|
} else {
|
||||||
const skillsData = getSkillsConfig(validatedProjectPath);
|
const skillsData = getSkillsConfig(validatedProjectPath, cliType);
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify(skillsData));
|
res.end(JSON.stringify(skillsData));
|
||||||
}
|
}
|
||||||
@@ -836,11 +862,13 @@ export async function handleSkillsRoutes(ctx: RouteContext): Promise<boolean> {
|
|||||||
// API: Get disabled skills list
|
// API: Get disabled skills list
|
||||||
if (pathname === '/api/skills/disabled' && req.method === 'GET') {
|
if (pathname === '/api/skills/disabled' && req.method === 'GET') {
|
||||||
const projectPathParam = url.searchParams.get('path') || initialPath;
|
const projectPathParam = url.searchParams.get('path') || initialPath;
|
||||||
|
const cliTypeParam = url.searchParams.get('cliType');
|
||||||
|
const cliType: CliType = cliTypeParam === 'codex' ? 'codex' : 'claude';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const validatedProjectPath = await validateAllowedPath(projectPathParam, { mustExist: true, allowedDirectories: [initialPath] });
|
const validatedProjectPath = await validateAllowedPath(projectPathParam, { mustExist: true, allowedDirectories: [initialPath] });
|
||||||
const disabledProjectSkills = getDisabledSkillsList('project', validatedProjectPath);
|
const disabledProjectSkills = getDisabledSkillsList('project', validatedProjectPath, cliType);
|
||||||
const disabledUserSkills = getDisabledSkillsList('user', validatedProjectPath);
|
const disabledUserSkills = getDisabledSkillsList('user', validatedProjectPath, cliType);
|
||||||
|
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({ disabledProjectSkills, disabledUserSkills }));
|
res.end(JSON.stringify({ disabledProjectSkills, disabledUserSkills }));
|
||||||
@@ -872,7 +900,9 @@ export async function handleSkillsRoutes(ctx: RouteContext): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const projectPath = projectPathParam || initialPath;
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -895,7 +925,9 @@ export async function handleSkillsRoutes(ctx: RouteContext): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const projectPath = projectPathParam || initialPath;
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -907,6 +939,9 @@ export async function handleSkillsRoutes(ctx: RouteContext): Promise<boolean> {
|
|||||||
const subPath = url.searchParams.get('subpath') || '';
|
const subPath = url.searchParams.get('subpath') || '';
|
||||||
const location = url.searchParams.get('location') || 'project';
|
const location = url.searchParams.get('location') || 'project';
|
||||||
const projectPathParam = url.searchParams.get('path') || initialPath;
|
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('..')) {
|
if (skillName.includes('/') || skillName.includes('\\') || skillName.includes('..')) {
|
||||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
@@ -918,7 +953,7 @@ export async function handleSkillsRoutes(ctx: RouteContext): Promise<boolean> {
|
|||||||
if (location === 'project') {
|
if (location === 'project') {
|
||||||
try {
|
try {
|
||||||
const validatedProjectPath = await validateAllowedPath(projectPathParam, { mustExist: true, allowedDirectories: [initialPath] });
|
const validatedProjectPath = await validateAllowedPath(projectPathParam, { mustExist: true, allowedDirectories: [initialPath] });
|
||||||
baseDir = join(validatedProjectPath, '.claude', 'skills');
|
baseDir = join(validatedProjectPath, dirCliDir, 'skills');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
const status = message.includes('Access denied') ? 403 : 400;
|
const status = message.includes('Access denied') ? 403 : 400;
|
||||||
@@ -928,7 +963,7 @@ export async function handleSkillsRoutes(ctx: RouteContext): Promise<boolean> {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
baseDir = join(homedir(), '.claude', 'skills');
|
baseDir = join(homedir(), dirCliDir, 'skills');
|
||||||
}
|
}
|
||||||
|
|
||||||
const skillRoot = join(baseDir, skillName);
|
const skillRoot = join(baseDir, skillName);
|
||||||
@@ -988,6 +1023,9 @@ export async function handleSkillsRoutes(ctx: RouteContext): Promise<boolean> {
|
|||||||
const fileName = url.searchParams.get('filename');
|
const fileName = url.searchParams.get('filename');
|
||||||
const location = url.searchParams.get('location') || 'project';
|
const location = url.searchParams.get('location') || 'project';
|
||||||
const projectPathParam = url.searchParams.get('path') || initialPath;
|
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) {
|
if (!fileName) {
|
||||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
@@ -1005,7 +1043,7 @@ export async function handleSkillsRoutes(ctx: RouteContext): Promise<boolean> {
|
|||||||
if (location === 'project') {
|
if (location === 'project') {
|
||||||
try {
|
try {
|
||||||
const validatedProjectPath = await validateAllowedPath(projectPathParam, { mustExist: true, allowedDirectories: [initialPath] });
|
const validatedProjectPath = await validateAllowedPath(projectPathParam, { mustExist: true, allowedDirectories: [initialPath] });
|
||||||
baseDir = join(validatedProjectPath, '.claude', 'skills');
|
baseDir = join(validatedProjectPath, fileCliDir, 'skills');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
const status = message.includes('Access denied') ? 403 : 400;
|
const status = message.includes('Access denied') ? 403 : 400;
|
||||||
@@ -1015,7 +1053,7 @@ export async function handleSkillsRoutes(ctx: RouteContext): Promise<boolean> {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
baseDir = join(homedir(), '.claude', 'skills');
|
baseDir = join(homedir(), fileCliDir, 'skills');
|
||||||
}
|
}
|
||||||
|
|
||||||
const skillRoot = join(baseDir, skillName);
|
const skillRoot = join(baseDir, skillName);
|
||||||
@@ -1082,12 +1120,16 @@ export async function handleSkillsRoutes(ctx: RouteContext): Promise<boolean> {
|
|||||||
return { error: 'Invalid skill name', status: 400 };
|
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;
|
let baseDir: string;
|
||||||
if (location === 'project') {
|
if (location === 'project') {
|
||||||
try {
|
try {
|
||||||
const projectRoot = projectPathParam || initialPath;
|
const projectRoot = projectPathParam || initialPath;
|
||||||
const validatedProjectPath = await validateAllowedPath(projectRoot, { mustExist: true, allowedDirectories: [initialPath] });
|
const validatedProjectPath = await validateAllowedPath(projectRoot, { mustExist: true, allowedDirectories: [initialPath] });
|
||||||
baseDir = join(validatedProjectPath, '.claude', 'skills');
|
baseDir = join(validatedProjectPath, writeCliDir, 'skills');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
const status = message.includes('Access denied') ? 403 : 400;
|
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 };
|
return { error: status === 403 ? 'Access denied' : 'Invalid path', status };
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
baseDir = join(homedir(), '.claude', 'skills');
|
baseDir = join(homedir(), writeCliDir, 'skills');
|
||||||
}
|
}
|
||||||
|
|
||||||
const skillRoot = join(baseDir, skillName);
|
const skillRoot = join(baseDir, skillName);
|
||||||
@@ -1128,7 +1170,9 @@ export async function handleSkillsRoutes(ctx: RouteContext): Promise<boolean> {
|
|||||||
const locationParam = url.searchParams.get('location');
|
const locationParam = url.searchParams.get('location');
|
||||||
const location: SkillLocation = locationParam === 'user' ? 'user' : 'project';
|
const location: SkillLocation = locationParam === 'user' ? 'user' : 'project';
|
||||||
const projectPathParam = url.searchParams.get('path') || initialPath;
|
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) {
|
if (skillDetail.error) {
|
||||||
res.writeHead(skillDetail.status || 404, { 'Content-Type': 'application/json' });
|
res.writeHead(skillDetail.status || 404, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({ error: skillDetail.error }));
|
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 location: SkillLocation = body.location === 'project' ? 'project' : 'user';
|
||||||
const projectPathParam = typeof body.projectPath === 'string' ? body.projectPath : undefined;
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1205,6 +1251,8 @@ export async function handleSkillsRoutes(ctx: RouteContext): Promise<boolean> {
|
|||||||
const description = typeof body.description === 'string' ? body.description : undefined;
|
const description = typeof body.description === 'string' ? body.description : undefined;
|
||||||
const generationType = typeof body.generationType === 'string' ? body.generationType : undefined;
|
const generationType = typeof body.generationType === 'string' ? body.generationType : undefined;
|
||||||
const projectPathParam = typeof body.projectPath === 'string' ? body.projectPath : 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) {
|
if (typeof mode !== 'string' || !mode) {
|
||||||
return { error: 'Mode is required (import or cli-generate)' };
|
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 { 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') {
|
} else if (mode === 'cli-generate') {
|
||||||
// CLI generate mode: use Claude to generate skill
|
// CLI generate mode: use Claude to generate skill
|
||||||
if (!skillName) {
|
if (!skillName) {
|
||||||
@@ -1265,7 +1313,8 @@ export async function handleSkillsRoutes(ctx: RouteContext): Promise<boolean> {
|
|||||||
skillName,
|
skillName,
|
||||||
location,
|
location,
|
||||||
projectPath: validatedProjectPath,
|
projectPath: validatedProjectPath,
|
||||||
broadcastToClients
|
broadcastToClients,
|
||||||
|
cliType: createCliType
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
return { error: 'Invalid mode. Must be "import" or "cli-generate"' };
|
return { error: 'Invalid mode. Must be "import" or "cli-generate"' };
|
||||||
|
|||||||
Reference in New Issue
Block a user