feat: add injection preview functionality and enhance specs management

- Implemented injection preview feature in InjectionControlTab with file listing and content preview.
- Added new API endpoint for fetching injection preview data.
- Introduced content length caching for performance optimization.
- Enhanced spec loading to support category filtering.
- Updated localization files for new features and terms.
- Created new personal and project specs for coding style and architecture constraints.
- Improved CLI options for category selection in spec commands.
This commit is contained in:
catlog22
2026-02-27 09:45:28 +08:00
parent dfa8e0d9f5
commit 3f25dbb11b
15 changed files with 648 additions and 120 deletions

View File

@@ -0,0 +1,27 @@
---
title: "Personal Coding Style"
dimension: personal
category: general
keywords:
- style
- preference
readMode: optional
priority: medium
---
# Personal Coding Style
## Preferences
- Describe your preferred coding style here
- Example: verbose variable names vs terse, functional vs imperative
## Patterns I Prefer
- List patterns you reach for most often
- Example: builder pattern, factory functions, tagged unions
## Things I Avoid
- List anti-patterns or approaches you dislike
- Example: deep inheritance hierarchies, magic strings

View File

@@ -0,0 +1,25 @@
---
title: "Tool Preferences"
dimension: personal
category: general
keywords:
- tool
- cli
- editor
readMode: optional
priority: low
---
# Tool Preferences
## Editor
- Preferred editor and key extensions/plugins
## CLI Tools
- Preferred shell, package manager, build tools
## Debugging
- Preferred debugging approach and tools

View File

@@ -0,0 +1,32 @@
---
title: "Architecture Constraints"
dimension: specs
category: planning
keywords:
- architecture
- module
- layer
- pattern
readMode: required
priority: high
---
# Architecture Constraints
## Module Boundaries
- Each module owns its data and exposes a public API
- No circular dependencies between modules
- Shared utilities live in a dedicated shared layer
## Layer Separation
- Presentation layer must not import data layer directly
- Business logic must be independent of framework specifics
- Configuration must be externalized, not hardcoded
## Dependency Rules
- External dependencies require justification
- Prefer standard library when available
- Pin dependency versions for reproducibility

View File

@@ -0,0 +1,38 @@
---
title: "Coding Conventions"
dimension: specs
category: general
keywords:
- typescript
- naming
- style
- convention
readMode: required
priority: high
---
# Coding Conventions
## Naming
- Use camelCase for variables and functions
- Use PascalCase for classes and interfaces
- Use UPPER_SNAKE_CASE for constants
## Formatting
- 2-space indentation
- Single quotes for strings
- Trailing commas in multi-line constructs
## Patterns
- Prefer composition over inheritance
- Use early returns to reduce nesting
- Keep functions under 30 lines when practical
## Error Handling
- Always handle errors explicitly
- Prefer typed errors over generic catch-all
- Log errors with sufficient context

View File

@@ -5,6 +5,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useIntl } from 'react-intl';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Settings, RefreshCw } from 'lucide-react'; import { Settings, RefreshCw } from 'lucide-react';
import { import {
@@ -113,6 +114,7 @@ const settingsKeys = {
// ========== Component ========== // ========== Component ==========
export function GlobalSettingsTab() { export function GlobalSettingsTab() {
const { formatMessage } = useIntl();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// Local state for immediate UI feedback // Local state for immediate UI feedback
@@ -149,10 +151,13 @@ export function GlobalSettingsTab() {
mutationFn: updateSystemSettings, mutationFn: updateSystemSettings,
onSuccess: (data) => { onSuccess: (data) => {
queryClient.setQueryData(settingsKeys.settings(), data.settings); queryClient.setQueryData(settingsKeys.settings(), data.settings);
toast.success('Settings saved successfully'); toast.success(formatMessage({ id: 'specs.injection.saveSuccess', defaultMessage: 'Settings saved successfully' }));
}, },
onError: (error) => { onError: (error) => {
toast.error(`Failed to save settings: ${error.message}`); toast.error(formatMessage(
{ id: 'specs.injection.saveError', defaultMessage: 'Failed to save settings: {error}' },
{ error: error.message }
));
}, },
}); });
@@ -194,12 +199,6 @@ export function GlobalSettingsTab() {
const isLoading = isLoadingSettings || isLoadingStats; const isLoading = isLoadingSettings || isLoadingStats;
const hasError = settingsError || statsError; const hasError = settingsError || statsError;
// Dimension display config
const dimensionLabels: Record<string, string> = {
specs: 'Specs',
personal: 'Personal',
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Personal Spec Defaults Card */} {/* Personal Spec Defaults Card */}
@@ -207,16 +206,20 @@ export function GlobalSettingsTab() {
<CardHeader> <CardHeader>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Settings className="h-5 w-5 text-muted-foreground" /> <Settings className="h-5 w-5 text-muted-foreground" />
<CardTitle>Personal Spec Defaults</CardTitle> <CardTitle>
{formatMessage({ id: 'specs.settings.personalSpecDefaults', defaultMessage: 'Personal Spec Defaults' })}
</CardTitle>
</div> </div>
<CardDescription> <CardDescription>
These settings will be applied when creating new personal specs {formatMessage({ id: 'specs.settings.personalSpecDefaultsDesc', defaultMessage: 'These settings will be applied when creating new personal specs' })}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
{/* Default Read Mode */} {/* Default Read Mode */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="default-read-mode">Default Read Mode</Label> <Label htmlFor="default-read-mode">
{formatMessage({ id: 'specs.settings.defaultReadMode', defaultMessage: 'Default Read Mode' })}
</Label>
<Select <Select
value={localDefaults.defaultReadMode} value={localDefaults.defaultReadMode}
onValueChange={(value) => onValueChange={(value) =>
@@ -224,28 +227,30 @@ export function GlobalSettingsTab() {
} }
> >
<SelectTrigger id="default-read-mode" className="w-full"> <SelectTrigger id="default-read-mode" className="w-full">
<SelectValue placeholder="Select read mode" /> <SelectValue placeholder={formatMessage({ id: 'specs.settings.selectReadMode', defaultMessage: 'Select read mode' })} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="required"> <SelectItem value="required">
Required (Always inject) {formatMessage({ id: 'specs.readMode.required', defaultMessage: 'Required' })}
</SelectItem> </SelectItem>
<SelectItem value="optional"> <SelectItem value="optional">
Optional (Inject on keyword match) {formatMessage({ id: 'specs.readMode.optional', defaultMessage: 'Optional' })}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
The default read mode for newly created personal specs {formatMessage({ id: 'specs.settings.defaultReadModeHelp', defaultMessage: 'The default read mode for newly created personal specs' })}
</p> </p>
</div> </div>
{/* Auto Enable */} {/* Auto Enable */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="space-y-0.5"> <div className="space-y-0.5">
<Label htmlFor="auto-enable">Auto Enable New Specs</Label> <Label htmlFor="auto-enable">
{formatMessage({ id: 'specs.settings.autoEnable', defaultMessage: 'Auto Enable New Specs' })}
</Label>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Automatically enable newly created personal specs {formatMessage({ id: 'specs.settings.autoEnableDescription', defaultMessage: 'Automatically enable newly created personal specs' })}
</p> </p>
</div> </div>
<Switch <Switch
@@ -262,7 +267,9 @@ export function GlobalSettingsTab() {
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle>Spec Statistics</CardTitle> <CardTitle>
{formatMessage({ id: 'specs.settings.specStatistics', defaultMessage: 'Spec Statistics' })}
</CardTitle>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -280,7 +287,7 @@ export function GlobalSettingsTab() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{isLoading ? ( {isLoading ? (
<div className="grid grid-cols-4 gap-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[1, 2, 3, 4].map((i) => ( {[1, 2, 3, 4].map((i) => (
<div <div
key={i} key={i}
@@ -293,11 +300,11 @@ export function GlobalSettingsTab() {
</div> </div>
) : hasError ? ( ) : hasError ? (
<div className="text-center py-8 text-muted-foreground"> <div className="text-center py-8 text-muted-foreground">
Failed to load statistics {formatMessage({ id: 'specs.injection.loadError', defaultMessage: 'Failed to load statistics' })}
</div> </div>
) : ( ) : (
<> <>
<div className="grid grid-cols-4 gap-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{dimensionEntries.map(([dim, data]) => ( {dimensionEntries.map(([dim, data]) => (
<div <div
key={dim} key={dim}
@@ -306,11 +313,11 @@ export function GlobalSettingsTab() {
<div className="text-2xl font-bold text-foreground"> <div className="text-2xl font-bold text-foreground">
{data.count} {data.count}
</div> </div>
<div className="text-sm text-muted-foreground capitalize"> <div className="text-sm text-muted-foreground">
{dimensionLabels[dim] || dim} {formatMessage({ id: `specs.dimension.${dim}`, defaultMessage: dim })}
</div> </div>
<div className="text-xs text-muted-foreground mt-1"> <div className="text-xs text-muted-foreground mt-1">
{data.requiredCount} required {data.requiredCount} {formatMessage({ id: 'specs.required', defaultMessage: 'required' })}
</div> </div>
</div> </div>
))} ))}
@@ -319,10 +326,15 @@ export function GlobalSettingsTab() {
{/* Summary */} {/* Summary */}
<div className="mt-4 pt-4 border-t border-border"> <div className="mt-4 pt-4 border-t border-border">
<div className="flex justify-between text-sm text-muted-foreground"> <div className="flex justify-between text-sm text-muted-foreground">
<span>Total: {totalCount} spec files</span>
<span> <span>
{totalRequired} required | {totalCount - totalRequired}{' '} {formatMessage(
optional { id: 'specs.settings.totalSpecs', defaultMessage: 'Total: {count} spec files' },
{ count: totalCount }
)}
</span>
<span>
{totalRequired} {formatMessage({ id: 'specs.readMode.required', defaultMessage: 'required' })} | {totalCount - totalRequired}{' '}
{formatMessage({ id: 'specs.readMode.optional', defaultMessage: 'optional' })}
</span> </span>
</div> </div>
</div> </div>

View File

@@ -8,6 +8,12 @@ import { useIntl } from 'react-intl';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/Dialog';
import { import {
Card, Card,
CardContent, CardContent,
@@ -20,6 +26,7 @@ import { Label } from '@/components/ui/Label';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Switch } from '@/components/ui/Switch'; import { Switch } from '@/components/ui/Switch';
import { Progress } from '@/components/ui/Progress'; import { Progress } from '@/components/ui/Progress';
import { Badge } from '@/components/ui/Badge';
import { import {
AlertCircle, AlertCircle,
Info, Info,
@@ -30,8 +37,16 @@ import {
Download, Download,
CheckCircle2, CheckCircle2,
Settings, Settings,
FileText,
Eye,
Globe,
Folder,
ChevronDown,
ChevronRight,
} from 'lucide-react'; } from 'lucide-react';
import { useInstallRecommendedHooks } from '@/hooks/useSystemSettings'; import { useInstallRecommendedHooks } from '@/hooks/useSystemSettings';
import type { InjectionPreviewFile, InjectionPreviewResponse } from '@/lib/api';
import { getInjectionPreview } from '@/lib/api';
// ========== Types ========== // ========== Types ==========
@@ -167,6 +182,17 @@ export function InjectionControlTab({ className }: InjectionControlTabProps) {
// State for hooks installation // State for hooks installation
const [installingHookIds, setInstallingHookIds] = useState<string[]>([]); const [installingHookIds, setInstallingHookIds] = useState<string[]>([]);
// State for injection preview
const [previewMode, setPreviewMode] = useState<'required' | 'all'>('required');
const [previewData, setPreviewData] = useState<InjectionPreviewResponse | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [expandedDimensions, setExpandedDimensions] = useState<Record<string, boolean>>({
specs: true,
personal: true,
});
const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
const [previewFile, setPreviewFile] = useState<InjectionPreviewFile | null>(null);
// Fetch stats // Fetch stats
const loadStats = useCallback(async () => { const loadStats = useCallback(async () => {
setStatsLoading(true); setStatsLoading(true);
@@ -195,12 +221,44 @@ export function InjectionControlTab({ className }: InjectionControlTabProps) {
} }
}, []); }, []);
// Load injection preview
const loadPreview = useCallback(async () => {
setPreviewLoading(true);
try {
const data = await getInjectionPreview(previewMode, false);
setPreviewData(data);
} catch (err) {
console.error('Failed to load injection preview:', err);
} finally {
setPreviewLoading(false);
}
}, [previewMode]);
// Load file content for preview
const loadFilePreview = useCallback(async (file: InjectionPreviewFile) => {
try {
const data = await getInjectionPreview(previewMode, true);
const fileWithData = data.files.find(f => f.file === file.file);
if (fileWithData) {
setPreviewFile(fileWithData);
setPreviewDialogOpen(true);
}
} catch (err) {
console.error('Failed to load file preview:', err);
}
}, [previewMode]);
// Initial load // Initial load
useEffect(() => { useEffect(() => {
loadStats(); loadStats();
loadSettings(); loadSettings();
}, [loadStats, loadSettings]); }, [loadStats, loadSettings]);
// Load preview when mode changes
useEffect(() => {
loadPreview();
}, [loadPreview]);
// Check for changes // Check for changes
useEffect(() => { useEffect(() => {
const changed = const changed =
@@ -245,20 +303,21 @@ export function InjectionControlTab({ className }: InjectionControlTabProps) {
setHasChanges(false); setHasChanges(false);
}; };
// Toggle dimension expansion
const toggleDimension = (dim: string) => {
setExpandedDimensions(prev => ({ ...prev, [dim]: !prev[dim] }));
};
// ========== Hooks Installation ========== // ========== Hooks Installation ==========
// Get installed hooks from system settings
const installedHookIds = useMemo(() => { const installedHookIds = useMemo(() => {
const installed = new Set<string>(); const installed = new Set<string>();
// Check if hooks are already installed by checking system settings
// For now, we'll track this via the mutation result
return installed; return installed;
}, []); }, []);
const installedCount = 0; // Will be updated when we have real data const installedCount = 0;
const allHooksInstalled = installedCount === RECOMMENDED_HOOKS.length; const allHooksInstalled = installedCount === RECOMMENDED_HOOKS.length;
// Install single hook
const handleInstallHook = useCallback(async (hookId: string) => { const handleInstallHook = useCallback(async (hookId: string) => {
setInstallingHookIds(prev => [...prev, hookId]); setInstallingHookIds(prev => [...prev, hookId]);
try { try {
@@ -276,7 +335,6 @@ export function InjectionControlTab({ className }: InjectionControlTabProps) {
} }
}, [installHooksMutation, formatMessage]); }, [installHooksMutation, formatMessage]);
// Install all hooks
const handleInstallAllHooks = useCallback(async () => { const handleInstallAllHooks = useCallback(async () => {
const uninstalledHooks = RECOMMENDED_HOOKS.filter(h => !installedHookIds.has(h.id)); const uninstalledHooks = RECOMMENDED_HOOKS.filter(h => !installedHookIds.has(h.id));
if (uninstalledHooks.length === 0) return; if (uninstalledHooks.length === 0) return;
@@ -300,6 +358,19 @@ export function InjectionControlTab({ className }: InjectionControlTabProps) {
} }
}, [installedHookIds, installHooksMutation, formatMessage]); }, [installedHookIds, installHooksMutation, formatMessage]);
// Group files by dimension
const filesByDimension = useMemo(() => {
if (!previewData) return {};
const grouped: Record<string, InjectionPreviewFile[]> = {};
for (const file of previewData.files) {
if (!grouped[file.dimension]) {
grouped[file.dimension] = [];
}
grouped[file.dimension].push(file);
}
return grouped;
}, [previewData]);
// Calculate progress and status // Calculate progress and status
const currentLength = stats?.injectionLength?.withKeywords || 0; const currentLength = stats?.injectionLength?.withKeywords || 0;
const maxLength = settings.maxLength; const maxLength = settings.maxLength;
@@ -416,7 +487,7 @@ export function InjectionControlTab({ className }: InjectionControlTabProps) {
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-6 w-6" className="h-6 w-6"
onClick={loadStats} onClick={() => { loadStats(); loadPreview(); }}
disabled={statsLoading} disabled={statsLoading}
> >
<RefreshCw className={cn('h-4 w-4', statsLoading && 'animate-spin')} /> <RefreshCw className={cn('h-4 w-4', statsLoading && 'animate-spin')} />
@@ -537,6 +608,116 @@ export function InjectionControlTab({ className }: InjectionControlTabProps) {
</CardContent> </CardContent>
</Card> </Card>
{/* Injection Files List Card */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
{formatMessage({ id: 'specs.injection.filesList', defaultMessage: 'Injection Files' })}
</CardTitle>
<div className="flex items-center gap-2">
<Button
variant={previewMode === 'required' ? 'default' : 'outline'}
size="sm"
onClick={() => setPreviewMode('required')}
>
{formatMessage({ id: 'specs.readMode.required', defaultMessage: 'Required' })}
</Button>
<Button
variant={previewMode === 'all' ? 'default' : 'outline'}
size="sm"
onClick={() => setPreviewMode('all')}
>
{formatMessage({ id: 'specs.scope.all', defaultMessage: 'All' })}
</Button>
</div>
</div>
<CardDescription>
{previewData && (
<span>
{previewData.stats.count} {formatMessage({ id: 'specs.injection.files', defaultMessage: 'files' })} {formatNumber(previewData.stats.totalLength)} {formatMessage({ id: 'specs.injection.characters', defaultMessage: 'characters' })}
</span>
)}
</CardDescription>
</CardHeader>
<CardContent>
{previewLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="h-[300px] overflow-auto">
<div className="space-y-2">
{Object.entries(filesByDimension).map(([dim, files]) => (
<div key={dim} className="border rounded-lg">
<button
className="w-full flex items-center justify-between p-3 hover:bg-muted/50 transition-colors"
onClick={() => toggleDimension(dim)}
>
<div className="flex items-center gap-2">
{expandedDimensions[dim] ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
<span className="font-medium capitalize">
{formatMessage({ id: `specs.dimension.${dim}`, defaultMessage: dim })}
</span>
<Badge variant="secondary" className="text-xs">
{files.length}
</Badge>
</div>
<span className="text-sm text-muted-foreground">
{formatNumber(files.reduce((sum, f) => sum + f.contentLength, 0))} {formatMessage({ id: 'specs.injection.characters', defaultMessage: 'chars' })}
</span>
</button>
{expandedDimensions[dim] && (
<div className="border-t">
{files.map((file) => (
<div
key={file.file}
className="flex items-center justify-between p-3 border-b last:border-b-0 hover:bg-muted/30"
>
<div className="flex items-center gap-3 min-w-0">
{file.scope === 'global' ? (
<Globe className="h-4 w-4 text-blue-500 flex-shrink-0" />
) : (
<Folder className="h-4 w-4 text-green-500 flex-shrink-0" />
)}
<div className="min-w-0">
<div className="font-medium truncate">{file.title}</div>
<div className="text-xs text-muted-foreground truncate">{file.file}</div>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
{formatMessage({ id: `specs.priority.${file.priority}`, defaultMessage: file.priority })}
</Badge>
<span className="text-xs text-muted-foreground whitespace-nowrap">
{formatNumber(file.contentLength)}
</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => loadFilePreview(file)}
>
<Eye className="h-3.5 w-3.5" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* Settings Card */} {/* Settings Card */}
<Card> <Card>
<CardHeader> <CardHeader>
@@ -645,6 +826,23 @@ export function InjectionControlTab({ className }: InjectionControlTabProps) {
)} )}
</CardContent> </CardContent>
</Card> </Card>
{/* File Preview Dialog */}
<Dialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}>
<DialogContent className="max-w-3xl max-h-[80vh]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
{previewFile?.title}
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-auto">
<pre className="text-sm whitespace-pre-wrap p-4 bg-muted rounded-lg">
{previewFile?.content || formatMessage({ id: 'specs.content.noContent', defaultMessage: 'No content available' })}
</pre>
</div>
</DialogContent>
</Dialog>
</div> </div>
); );
} }

View File

@@ -7273,6 +7273,8 @@ export interface SpecEntry {
priority: 'critical' | 'high' | 'medium' | 'low'; priority: 'critical' | 'high' | 'medium' | 'low';
keywords: string[]; keywords: string[];
scope: 'global' | 'project'; scope: 'global' | 'project';
/** Content length (body only, cached for performance) */
contentLength: number;
} }
/** /**
@@ -7305,6 +7307,54 @@ export async function rebuildSpecIndex(projectPath?: string): Promise<{ success:
}); });
} }
/**
* Injection preview file info
*/
export interface InjectionPreviewFile {
file: string;
title: string;
dimension: string;
category: string;
scope: string;
readMode: string;
priority: string;
contentLength: number;
content?: string;
}
/**
* Injection preview response
*/
export interface InjectionPreviewResponse {
files: InjectionPreviewFile[];
stats: {
count: number;
totalLength: number;
maxLength: number;
percentage: number;
};
}
/**
* Get injection preview with file list
* @param mode - 'required' | 'all' | 'keywords'
* @param preview - Include content preview
* @param projectPath - Optional project path
*/
export async function getInjectionPreview(
mode: 'required' | 'all' | 'keywords' = 'required',
preview: boolean = false,
projectPath?: string
): Promise<InjectionPreviewResponse> {
const params = new URLSearchParams();
params.set('mode', mode);
params.set('preview', String(preview));
if (projectPath) {
params.set('path', projectPath);
}
return fetchApi<InjectionPreviewResponse>(`/api/specs/injection-preview?${params.toString()}`);
}
/** /**
* Update spec frontmatter (toggle readMode) * Update spec frontmatter (toggle readMode)
*/ */

View File

@@ -14,7 +14,9 @@
"dimension": { "dimension": {
"specs": "Project Specs", "specs": "Project Specs",
"personal": "Personal" "personal": "Personal",
"roadmap": "Roadmap",
"changelog": "Changelog"
}, },
"scope": { "scope": {
@@ -23,8 +25,10 @@
"project": "Project" "project": "Project"
}, },
"filterByScope": "Filter by scope:", "filterByScope": "Filter by scope:",
"filterByCategory": "Workflow stage:",
"category": { "category": {
"all": "All",
"general": "General", "general": "General",
"exploration": "Exploration", "exploration": "Exploration",
"planning": "Planning", "planning": "Planning",
@@ -43,11 +47,38 @@
"install": "Install", "install": "Install",
"installed": "Installed", "installed": "Installed",
"installing": "Installing...", "installing": "Installing...",
"installedHooks": "Installed Hooks", "installedHooks": "Installed Hooks",
"installedHooksDesc": "Manage your installed hooks configuration", "installedHooksDesc": "Manage your installed hooks configuration",
"searchHooks": "Search hooks...", "searchHooks": "Search hooks...",
"noHooks": "No hooks installed. Install recommended hooks above.", "noHooks": "No hooks installed. Install recommended hooks above.",
"actions": {
"view": "View Content",
"edit": "Edit",
"delete": "Delete",
"reset": "Reset",
"save": "Save",
"saving": "Saving..."
},
"status": {
"enabled": "Enabled",
"disabled": "Disabled"
},
"readMode": {
"required": "Required",
"optional": "Optional"
},
"priority": {
"critical": "Critical",
"high": "High",
"medium": "Medium",
"low": "Low"
},
"spec": { "spec": {
"edit": "Edit Spec", "edit": "Edit Spec",
"toggle": "Toggle Status", "toggle": "Toggle Status",
@@ -91,37 +122,11 @@
"failModeWarn": "Warn" "failModeWarn": "Warn"
}, },
"actions": {
"edit": "Edit",
"delete": "Delete",
"reset": "Reset",
"save": "Save",
"saving": "Saving...",
"view": "View Content"
},
"status": {
"enabled": "Enabled",
"disabled": "Disabled"
},
"readMode": {
"required": "Required",
"optional": "Optional"
},
"priority": {
"critical": "Critical",
"high": "High",
"medium": "Medium",
"low": "Low"
},
"hooks": { "hooks": {
"dialog": { "dialog": {
"createTitle": "Create Hook", "createTitle": "Create Hook",
"editTitle": "Edit Hook", "editTitle": "Edit Hook",
"description": "Configure the hook trigger event, command, and other settings." "description": "Configure hook trigger event, command, and other settings."
}, },
"fields": { "fields": {
"name": "Hook Name", "name": "Hook Name",
@@ -149,9 +154,9 @@
"project": "Project" "project": "Project"
}, },
"failModes": { "failModes": {
"continue": "Continue", "continue": "Continue execution",
"warn": "Show Warning", "warn": "Show warning",
"block": "Block Operation" "block": "Block operation"
}, },
"validation": { "validation": {
"nameRequired": "Name is required", "nameRequired": "Name is required",
@@ -185,7 +190,7 @@
"metadata": "Metadata", "metadata": "Metadata",
"markdownContent": "Markdown Content", "markdownContent": "Markdown Content",
"noContent": "No content available", "noContent": "No content available",
"editHint": "Edit the full markdown content including frontmatter. Changes to frontmatter will be reflected in the spec metadata.", "editHint": "Edit the full markdown content including frontmatter. Changes to frontmatter will be reflected in spec metadata.",
"placeholder": "# Spec Title\n\nContent here..." "placeholder": "# Spec Title\n\nContent here..."
}, },
@@ -221,10 +226,14 @@
"title": "Global Settings", "title": "Global Settings",
"description": "Configure personal spec defaults and system settings", "description": "Configure personal spec defaults and system settings",
"personalSpecDefaults": "Personal Spec Defaults", "personalSpecDefaults": "Personal Spec Defaults",
"personalSpecDefaultsDesc": "These settings will be applied when creating new personal specs",
"defaultReadMode": "Default Read Mode", "defaultReadMode": "Default Read Mode",
"defaultReadModeHelp": "Default read mode for newly created personal specs", "defaultReadModeHelp": "The default read mode for newly created personal specs",
"autoEnable": "Auto Enable", "selectReadMode": "Select read mode",
"autoEnableDescription": "Automatically enable newly created personal specs" "autoEnable": "Auto Enable New Specs",
"autoEnableDescription": "Automatically enable newly created personal specs",
"specStatistics": "Spec Statistics",
"totalSpecs": "Total: {count} spec files"
}, },
"dialog": { "dialog": {

View File

@@ -14,7 +14,9 @@
"dimension": { "dimension": {
"specs": "项目规范", "specs": "项目规范",
"personal": "个人规范" "personal": "个人规范",
"roadmap": "路线图",
"changelog": "变更日志"
}, },
"scope": { "scope": {
@@ -23,8 +25,10 @@
"project": "项目" "project": "项目"
}, },
"filterByScope": "按范围筛选:", "filterByScope": "按范围筛选:",
"filterByCategory": "工作流阶段:",
"category": { "category": {
"all": "全部",
"general": "通用", "general": "通用",
"exploration": "探索", "exploration": "探索",
"planning": "规划", "planning": "规划",
@@ -76,7 +80,6 @@
}, },
"spec": { "spec": {
"view": "查看内容",
"edit": "编辑规范", "edit": "编辑规范",
"toggle": "切换状态", "toggle": "切换状态",
"delete": "删除规范", "delete": "删除规范",
@@ -89,23 +92,6 @@
"file": "文件路径" "file": "文件路径"
}, },
"content": {
"edit": "编辑",
"view": "查看",
"metadata": "元数据",
"markdownContent": "Markdown 内容",
"noContent": "无内容",
"editHint": "编辑完整的 Markdown 内容(包括 frontmatter。frontmatter 的更改将反映到规范元数据中。",
"placeholder": "# 规范标题\n\n内容..."
},
"common": {
"cancel": "取消",
"save": "保存",
"saving": "保存中...",
"close": "关闭"
},
"hook": { "hook": {
"install": "安装", "install": "安装",
"uninstall": "卸载", "uninstall": "卸载",
@@ -137,9 +123,6 @@
}, },
"hooks": { "hooks": {
"installSuccess": "钩子安装成功",
"installError": "钩子安装失败",
"installAllSuccess": "所有钩子安装成功",
"dialog": { "dialog": {
"createTitle": "创建钩子", "createTitle": "创建钩子",
"editTitle": "编辑钩子", "editTitle": "编辑钩子",
@@ -191,6 +174,26 @@
"hookFailMode": "命令执行失败时的处理方式" "hookFailMode": "命令执行失败时的处理方式"
}, },
"common": {
"cancel": "取消",
"save": "保存",
"delete": "删除",
"edit": "编辑",
"reset": "重置",
"confirm": "确认",
"close": "关闭"
},
"content": {
"edit": "编辑",
"view": "查看",
"metadata": "元数据",
"markdownContent": "Markdown 内容",
"noContent": "无可用内容",
"editHint": "编辑完整的 markdown 内容包括 frontmatter。对 frontmatter 的更改将反映在规范元数据中。",
"placeholder": "# 规范标题\n\n内容在这里..."
},
"injection": { "injection": {
"title": "注入控制", "title": "注入控制",
"statusTitle": "当前注入状态", "statusTitle": "当前注入状态",
@@ -210,23 +213,37 @@
"warning": "接近限制", "warning": "接近限制",
"normal": "正常", "normal": "正常",
"characters": "字符", "characters": "字符",
"chars": "字符",
"statsInfo": "统计信息", "statsInfo": "统计信息",
"requiredLength": "必读规范长度:", "requiredLength": "必读规范长度:",
"matchedLength": "关键词匹配长度:", "matchedLength": "关键词匹配长度:",
"remaining": "剩余空间:", "remaining": "剩余空间:",
"loadError": "加载统计数据失败", "loadError": "加载统计数据失败",
"saveSuccess": "设置已保存", "saveSuccess": "设置已保存",
"saveError": "保存设置失败" "saveError": "保存设置失败",
"filesList": "注入文件列表",
"files": "个文件"
},
"priority": {
"critical": "关键",
"high": "高",
"medium": "中",
"low": "低"
}, },
"settings": { "settings": {
"title": "全局设置", "title": "全局设置",
"description": "配置个人规范默认值和系统设置", "description": "配置个人规范默认值和系统设置",
"personalSpecDefaults": "个人规范默认值", "personalSpecDefaults": "个人规范默认值",
"personalSpecDefaultsDesc": "创建新的个人规范时将应用这些设置",
"defaultReadMode": "默认读取模式", "defaultReadMode": "默认读取模式",
"defaultReadModeHelp": "新创建的个人规范的默认读取模式", "defaultReadModeHelp": "新创建的个人规范的默认读取模式",
"autoEnable": "自动启用", "selectReadMode": "选择读取模式",
"autoEnableDescription": "新创建的个人规范自动启用" "autoEnable": "自动启用新规范",
"autoEnableDescription": "自动启用新创建的个人规范",
"specStatistics": "规范统计",
"totalSpecs": "总计:{count} 个规范文件"
}, },
"dialog": { "dialog": {

View File

@@ -303,6 +303,7 @@ export function run(argv: string[]): void {
.command('spec [subcommand] [args...]') .command('spec [subcommand] [args...]')
.description('Project spec management for conventions and guidelines') .description('Project spec management for conventions and guidelines')
.option('--dimension <dim>', 'Target dimension: specs, personal') .option('--dimension <dim>', 'Target dimension: specs, personal')
.option('--category <cat>', 'Workflow stage: general, exploration, planning, execution')
.option('--keywords <text>', 'Keywords for spec matching (CLI mode)') .option('--keywords <text>', 'Keywords for spec matching (CLI mode)')
.option('--stdin', 'Read input from stdin (Hook mode)') .option('--stdin', 'Read input from stdin (Hook mode)')
.option('--json', 'Output as JSON') .option('--json', 'Output as JSON')
@@ -374,3 +375,6 @@ export function run(argv: string[]): void {
program.parse(argv); program.parse(argv);
} }
// Invoke CLI when run directly
run(process.argv);

View File

@@ -11,6 +11,7 @@ import chalk from 'chalk';
interface SpecOptions { interface SpecOptions {
dimension?: string; dimension?: string;
category?: string;
keywords?: string; keywords?: string;
stdin?: boolean; stdin?: boolean;
json?: boolean; json?: boolean;
@@ -58,13 +59,13 @@ function getProjectPath(hookCwd?: string): string {
// ============================================================================ // ============================================================================
/** /**
* Load action - load specs matching dimension/keywords. * Load action - load specs matching dimension/category/keywords.
* *
* CLI mode: --dimension and --keywords options, outputs formatted markdown. * CLI mode: --dimension, --category, --keywords options, outputs formatted markdown.
* Hook mode: --stdin reads JSON {session_id, cwd, user_prompt}, outputs JSON {continue, systemMessage}. * Hook mode: --stdin reads JSON {session_id, cwd, user_prompt}, outputs JSON {continue, systemMessage}.
*/ */
async function loadAction(options: SpecOptions): Promise<void> { async function loadAction(options: SpecOptions): Promise<void> {
const { stdin, dimension, keywords: keywordsInput } = options; const { stdin, dimension, category, keywords: keywordsInput } = options;
let projectPath: string; let projectPath: string;
let stdinData: StdinData | undefined; let stdinData: StdinData | undefined;
@@ -96,6 +97,7 @@ async function loadAction(options: SpecOptions): Promise<void> {
const result = await loadSpecs({ const result = await loadSpecs({
projectPath, projectPath,
dimension: dimension as 'specs' | 'personal' | undefined, dimension: dimension as 'specs' | 'personal' | undefined,
category: category as 'general' | 'exploration' | 'planning' | 'execution' | undefined,
keywords, keywords,
outputFormat: stdin ? 'hook' : 'cli', outputFormat: stdin ? 'hook' : 'cli',
stdinData, stdinData,

View File

@@ -151,7 +151,7 @@ export async function handleSpecRoutes(ctx: RouteContext): Promise<boolean> {
return true; return true;
} }
// API: Get spec stats (dimensions count + injection length info) // API: Get spec stats (optimized - uses cached contentLength)
if (pathname === '/api/specs/stats' && req.method === 'GET') { if (pathname === '/api/specs/stats' && req.method === 'GET') {
const projectPath = url.searchParams.get('path') || initialPath; const projectPath = url.searchParams.get('path') || initialPath;
const resolvedPath = resolvePath(projectPath); const resolvedPath = resolvePath(projectPath);
@@ -186,18 +186,8 @@ export async function handleSpecRoutes(ctx: RouteContext): Promise<boolean> {
for (const entry of index.entries) { for (const entry of index.entries) {
count++; count++;
// Calculate content length by reading the file // Use cached contentLength instead of re-reading file
const filePath = join(resolvedPath, entry.file); const contentLength = entry.contentLength || 0;
let contentLength = 0;
try {
if (existsSync(filePath)) {
const rawContent = readFileSync(filePath, 'utf-8');
// Strip frontmatter to get actual content length
const matter = (await import('gray-matter')).default;
const parsed = matter(rawContent);
contentLength = parsed.content.length;
}
} catch { /* ignore */ }
if (entry.readMode === 'required') { if (entry.readMode === 'required') {
requiredCount++; requiredCount++;
@@ -228,5 +218,109 @@ export async function handleSpecRoutes(ctx: RouteContext): Promise<boolean> {
return true; return true;
} }
// API: Get injection preview (files list and content preview)
if (pathname === '/api/specs/injection-preview' && req.method === 'GET') {
const projectPath = url.searchParams.get('path') || initialPath;
const resolvedPath = resolvePath(projectPath);
const mode = url.searchParams.get('mode') || 'required'; // required | all | keywords
const preview = url.searchParams.get('preview') === 'true';
try {
const { getDimensionIndex, SPEC_DIMENSIONS } = await import(
'../../tools/spec-index-builder.js'
);
interface InjectionFile {
file: string;
title: string;
dimension: string;
category: string;
scope: string;
readMode: string;
priority: string;
contentLength: number;
content?: string;
}
const files: InjectionFile[] = [];
let totalLength = 0;
for (const dim of SPEC_DIMENSIONS) {
const index = await getDimensionIndex(resolvedPath, dim);
for (const entry of index.entries) {
// Filter by mode
if (mode === 'required' && entry.readMode !== 'required') {
continue;
}
const fileData: InjectionFile = {
file: entry.file,
title: entry.title,
dimension: entry.dimension,
category: entry.category || 'general',
scope: entry.scope,
readMode: entry.readMode,
priority: entry.priority,
contentLength: entry.contentLength || 0
};
// Include content if preview requested
if (preview) {
const filePath = join(resolvedPath, entry.file);
if (existsSync(filePath)) {
try {
const rawContent = readFileSync(filePath, 'utf-8');
const matter = (await import('gray-matter')).default;
const parsed = matter(rawContent);
fileData.content = parsed.content.trim();
} catch {
fileData.content = '';
}
}
}
files.push(fileData);
totalLength += fileData.contentLength;
}
}
// Sort by priority
const priorityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
files.sort((a, b) =>
(priorityOrder[a.priority as keyof typeof priorityOrder] || 2) -
(priorityOrder[b.priority as keyof typeof priorityOrder] || 2)
);
// Get maxLength for percentage calculation
let maxLength = 8000;
const settingsPath = join(homedir(), '.claude', 'settings.json');
if (existsSync(settingsPath)) {
try {
const rawSettings = readFileSync(settingsPath, 'utf-8');
const settings = JSON.parse(rawSettings) as {
system?: { injectionControl?: { maxLength?: number } };
};
maxLength = settings?.system?.injectionControl?.maxLength || 8000;
} catch { /* ignore */ }
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
files,
stats: {
count: files.length,
totalLength,
maxLength,
percentage: Math.round((totalLength / maxLength) * 100)
}
}));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: (err as Error).message }));
}
return true;
}
return false; return false;
} }

View File

@@ -626,11 +626,12 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
if (await handleFilesRoutes(routeContext)) return; if (await handleFilesRoutes(routeContext)) return;
} }
// System routes (data, health, version, paths, shutdown, notify, storage, dialog, a2ui answer broker) // System routes (data, health, version, paths, shutdown, notify, storage, dialog, a2ui answer broker, system settings)
if (pathname === '/api/data' || pathname === '/api/health' || if (pathname === '/api/data' || pathname === '/api/health' ||
pathname === '/api/version-check' || pathname === '/api/shutdown' || pathname === '/api/version-check' || pathname === '/api/shutdown' ||
pathname === '/api/recent-paths' || pathname === '/api/switch-path' || pathname === '/api/recent-paths' || pathname === '/api/switch-path' ||
pathname === '/api/remove-recent-path' || pathname === '/api/system/notify' || pathname === '/api/remove-recent-path' || pathname === '/api/system/notify' ||
pathname === '/api/system/settings' || pathname === '/api/system/hooks/install-recommended' ||
pathname === '/api/a2ui/answer' || pathname === '/api/a2ui/answer' ||
pathname.startsWith('/api/storage/') || pathname.startsWith('/api/dialog/')) { pathname.startsWith('/api/storage/') || pathname.startsWith('/api/dialog/')) {
if (await handleSystemRoutes(routeContext)) return; if (await handleSystemRoutes(routeContext)) return;

View File

@@ -74,6 +74,8 @@ export interface SpecIndexEntry {
priority: 'critical' | 'high' | 'medium' | 'low'; priority: 'critical' | 'high' | 'medium' | 'low';
/** Scope: global (from ~/.ccw/) or project (from .ccw/) */ /** Scope: global (from ~/.ccw/) or project (from .ccw/) */
scope: 'global' | 'project'; scope: 'global' | 'project';
/** Content length (body only, without frontmatter) - cached for performance */
contentLength: number;
} }
/** /**
@@ -347,16 +349,18 @@ function parseSpecFile(
} }
const data = parsed.data as Record<string, unknown>; const data = parsed.data as Record<string, unknown>;
// Calculate content length (body only, without frontmatter)
const contentLength = parsed.content.length;
// Extract and validate frontmatter fields // Extract and validate frontmatter fields
const title = extractString(data, 'title'); const title = extractString(data, 'title');
if (!title) { if (!title) {
// Title is required - use filename as fallback // Title is required - use filename as fallback
const fallbackTitle = basename(filePath, extname(filePath)); const fallbackTitle = basename(filePath, extname(filePath));
return buildEntry(fallbackTitle, filePath, dimension, projectPath, data, scope); return buildEntry(fallbackTitle, filePath, dimension, projectPath, data, scope, contentLength);
} }
return buildEntry(title, filePath, dimension, projectPath, data, scope); return buildEntry(title, filePath, dimension, projectPath, data, scope, contentLength);
} }
/** /**
@@ -368,7 +372,8 @@ function buildEntry(
dimension: string, dimension: string,
projectPath: string, projectPath: string,
data: Record<string, unknown>, data: Record<string, unknown>,
scope: 'global' | 'project' = 'project' scope: 'global' | 'project' = 'project',
contentLength: number = 0
): SpecIndexEntry { ): SpecIndexEntry {
// Compute relative file path from project root using path.relative // Compute relative file path from project root using path.relative
// Normalize to forward slashes for cross-platform consistency // Normalize to forward slashes for cross-platform consistency
@@ -398,6 +403,7 @@ function buildEntry(
readMode, readMode,
priority, priority,
scope, scope,
contentLength,
}; };
} }

View File

@@ -24,6 +24,7 @@ import {
SPEC_DIMENSIONS, SPEC_DIMENSIONS,
SPEC_CATEGORIES, SPEC_CATEGORIES,
type SpecDimension, type SpecDimension,
type SpecCategory,
} from './spec-index-builder.js'; } from './spec-index-builder.js';
import { import {
@@ -43,6 +44,8 @@ export interface SpecLoadOptions {
projectPath: string; projectPath: string;
/** Specific dimension to load (loads all if omitted) */ /** Specific dimension to load (loads all if omitted) */
dimension?: SpecDimension; dimension?: SpecDimension;
/** Workflow stage category filter (loads matching category specs) */
category?: SpecCategory;
/** Pre-extracted keywords (skips extraction if provided) */ /** Pre-extracted keywords (skips extraction if provided) */
keywords?: string[]; keywords?: string[];
/** Output format: 'cli' for markdown, 'hook' for JSON */ /** Output format: 'cli' for markdown, 'hook' for JSON */
@@ -138,7 +141,7 @@ const SPEC_PRIORITY_WEIGHT: Record<string, number> = {
* @returns SpecLoadResult with formatted content * @returns SpecLoadResult with formatted content
*/ */
export async function loadSpecs(options: SpecLoadOptions): Promise<SpecLoadResult> { export async function loadSpecs(options: SpecLoadOptions): Promise<SpecLoadResult> {
const { projectPath, outputFormat, debug } = options; const { projectPath, outputFormat, debug, category } = options;
// Get injection control settings // Get injection control settings
const maxLength = options.maxLength ?? 8000; const maxLength = options.maxLength ?? 8000;
@@ -149,6 +152,9 @@ export async function loadSpecs(options: SpecLoadOptions): Promise<SpecLoadResul
if (debug) { if (debug) {
debugLog(`Extracted ${keywords.length} keywords: [${keywords.join(', ')}]`); debugLog(`Extracted ${keywords.length} keywords: [${keywords.join(', ')}]`);
if (category) {
debugLog(`Category filter: ${category}`);
}
} }
// Step 2: Determine which dimensions to process // Step 2: Determine which dimensions to process
@@ -164,7 +170,7 @@ export async function loadSpecs(options: SpecLoadOptions): Promise<SpecLoadResul
const index = await getDimensionIndex(projectPath, dim); const index = await getDimensionIndex(projectPath, dim);
totalScanned += index.entries.length; totalScanned += index.entries.length;
const { required, matched } = filterSpecs(index, keywords); const { required, matched } = filterSpecs(index, keywords, category);
if (debug) { if (debug) {
debugLog( debugLog(
@@ -229,23 +235,30 @@ export async function loadSpecs(options: SpecLoadOptions): Promise<SpecLoadResul
// ============================================================================ // ============================================================================
/** /**
* Filter specs by readMode and keyword match. * Filter specs by readMode, category, and keyword match.
* *
* - required: all entries with readMode === 'required' * - required: all entries with readMode === 'required' (and matching category if specified)
* - matched: entries with readMode === 'optional' that have keyword intersection * - matched: entries with readMode === 'optional' that have keyword intersection (and matching category if specified)
* *
* @param index - The dimension index to filter * @param index - The dimension index to filter
* @param keywords - Extracted prompt keywords * @param keywords - Extracted prompt keywords
* @param category - Optional category filter for workflow stage
* @returns Separated required and matched entries (deduplicated) * @returns Separated required and matched entries (deduplicated)
*/ */
export function filterSpecs( export function filterSpecs(
index: DimensionIndex, index: DimensionIndex,
keywords: string[] keywords: string[],
category?: SpecCategory
): { required: SpecIndexEntry[]; matched: SpecIndexEntry[] } { ): { required: SpecIndexEntry[]; matched: SpecIndexEntry[] } {
const required: SpecIndexEntry[] = []; const required: SpecIndexEntry[] = [];
const matched: SpecIndexEntry[] = []; const matched: SpecIndexEntry[] = [];
for (const entry of index.entries) { for (const entry of index.entries) {
// Category filter: skip if category specified and doesn't match
if (category && entry.category !== category) {
continue;
}
if (entry.readMode === 'required') { if (entry.readMode === 'required') {
required.push(entry); required.push(entry);
continue; continue;