@evolve.labs/devflow 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/agents/architect.md +1162 -0
- package/.claude/commands/agents/architect.meta.yaml +124 -0
- package/.claude/commands/agents/builder.md +1432 -0
- package/.claude/commands/agents/builder.meta.yaml +117 -0
- package/.claude/commands/agents/chronicler.md +633 -0
- package/.claude/commands/agents/chronicler.meta.yaml +217 -0
- package/.claude/commands/agents/guardian.md +456 -0
- package/.claude/commands/agents/guardian.meta.yaml +127 -0
- package/.claude/commands/agents/strategist.md +483 -0
- package/.claude/commands/agents/strategist.meta.yaml +158 -0
- package/.claude/commands/agents/system-designer.md +1137 -0
- package/.claude/commands/agents/system-designer.meta.yaml +156 -0
- package/.claude/commands/devflow-help.md +93 -0
- package/.claude/commands/devflow-status.md +60 -0
- package/.claude/commands/quick/create-adr.md +82 -0
- package/.claude/commands/quick/new-feature.md +57 -0
- package/.claude/commands/quick/security-check.md +54 -0
- package/.claude/commands/quick/system-design.md +58 -0
- package/.claude_project +52 -0
- package/.devflow/agents/architect.meta.yaml +122 -0
- package/.devflow/agents/builder.meta.yaml +116 -0
- package/.devflow/agents/chronicler.meta.yaml +222 -0
- package/.devflow/agents/guardian.meta.yaml +127 -0
- package/.devflow/agents/strategist.meta.yaml +158 -0
- package/.devflow/agents/system-designer.meta.yaml +265 -0
- package/.devflow/project.yaml +242 -0
- package/.gitignore-template +84 -0
- package/LICENSE +21 -0
- package/README.md +249 -0
- package/bin/devflow.js +54 -0
- package/lib/autopilot.js +235 -0
- package/lib/autopilotConstants.js +213 -0
- package/lib/constants.js +95 -0
- package/lib/init.js +200 -0
- package/lib/update.js +181 -0
- package/lib/utils.js +157 -0
- package/lib/web.js +119 -0
- package/package.json +57 -0
- package/web/CHANGELOG.md +192 -0
- package/web/README.md +156 -0
- package/web/app/api/autopilot/execute/route.ts +102 -0
- package/web/app/api/autopilot/terminal-execute/route.ts +124 -0
- package/web/app/api/files/route.ts +280 -0
- package/web/app/api/files/tree/route.ts +160 -0
- package/web/app/api/git/route.ts +201 -0
- package/web/app/api/health/route.ts +94 -0
- package/web/app/api/project/open/route.ts +134 -0
- package/web/app/api/search/route.ts +247 -0
- package/web/app/api/specs/route.ts +405 -0
- package/web/app/api/terminal/route.ts +222 -0
- package/web/app/globals.css +160 -0
- package/web/app/ide/layout.tsx +43 -0
- package/web/app/ide/page.tsx +216 -0
- package/web/app/layout.tsx +34 -0
- package/web/app/page.tsx +303 -0
- package/web/components/agents/AgentIcons.tsx +281 -0
- package/web/components/autopilot/AutopilotConfigModal.tsx +245 -0
- package/web/components/autopilot/AutopilotPanel.tsx +299 -0
- package/web/components/dashboard/DashboardPanel.tsx +393 -0
- package/web/components/editor/Breadcrumbs.tsx +134 -0
- package/web/components/editor/EditorPanel.tsx +120 -0
- package/web/components/editor/EditorTabs.tsx +229 -0
- package/web/components/editor/MarkdownPreview.tsx +154 -0
- package/web/components/editor/MermaidDiagram.tsx +113 -0
- package/web/components/editor/MonacoEditor.tsx +177 -0
- package/web/components/editor/TabContextMenu.tsx +207 -0
- package/web/components/git/GitPanel.tsx +534 -0
- package/web/components/layout/Shell.tsx +15 -0
- package/web/components/layout/StatusBar.tsx +100 -0
- package/web/components/modals/CommandPalette.tsx +393 -0
- package/web/components/modals/GlobalSearch.tsx +348 -0
- package/web/components/modals/QuickOpen.tsx +241 -0
- package/web/components/modals/RecentFiles.tsx +208 -0
- package/web/components/projects/ProjectSelector.tsx +147 -0
- package/web/components/settings/SettingItem.tsx +150 -0
- package/web/components/settings/SettingsPanel.tsx +323 -0
- package/web/components/specs/SpecsPanel.tsx +1091 -0
- package/web/components/terminal/TerminalPanel.tsx +683 -0
- package/web/components/ui/ContextMenu.tsx +182 -0
- package/web/components/ui/LoadingSpinner.tsx +66 -0
- package/web/components/ui/ResizeHandle.tsx +110 -0
- package/web/components/ui/Skeleton.tsx +108 -0
- package/web/components/ui/SkipLinks.tsx +37 -0
- package/web/components/ui/Toaster.tsx +57 -0
- package/web/hooks/useFocusTrap.ts +141 -0
- package/web/hooks/useKeyboardShortcuts.ts +169 -0
- package/web/hooks/useListNavigation.ts +237 -0
- package/web/lib/autopilotConstants.ts +213 -0
- package/web/lib/constants/agents.ts +67 -0
- package/web/lib/git.ts +339 -0
- package/web/lib/ptyManager.ts +191 -0
- package/web/lib/specsParser.ts +299 -0
- package/web/lib/stores/autopilotStore.ts +288 -0
- package/web/lib/stores/fileStore.ts +550 -0
- package/web/lib/stores/gitStore.ts +386 -0
- package/web/lib/stores/projectStore.ts +196 -0
- package/web/lib/stores/settingsStore.ts +126 -0
- package/web/lib/stores/specsStore.ts +297 -0
- package/web/lib/stores/uiStore.ts +175 -0
- package/web/lib/types/index.ts +177 -0
- package/web/lib/utils.ts +98 -0
- package/web/next.config.js +50 -0
- package/web/package.json +54 -0
- package/web/postcss.config.js +6 -0
- package/web/tailwind.config.ts +68 -0
- package/web/tsconfig.json +41 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import {
|
|
3
|
+
getGitStatus,
|
|
4
|
+
getGitLog,
|
|
5
|
+
getGitBranches,
|
|
6
|
+
getGitDiff,
|
|
7
|
+
gitStage,
|
|
8
|
+
gitUnstage,
|
|
9
|
+
gitCommit,
|
|
10
|
+
gitPush,
|
|
11
|
+
gitPull,
|
|
12
|
+
gitCheckout,
|
|
13
|
+
gitCreateBranch,
|
|
14
|
+
gitDiscard,
|
|
15
|
+
gitInit,
|
|
16
|
+
getRemotes,
|
|
17
|
+
} from '@/lib/git';
|
|
18
|
+
|
|
19
|
+
// GET - Get git status, log, branches, or diff
|
|
20
|
+
export async function GET(request: NextRequest) {
|
|
21
|
+
const { searchParams } = new URL(request.url);
|
|
22
|
+
const action = searchParams.get('action') || 'status';
|
|
23
|
+
const projectPath = searchParams.get('projectPath');
|
|
24
|
+
const file = searchParams.get('file');
|
|
25
|
+
const staged = searchParams.get('staged') === 'true';
|
|
26
|
+
|
|
27
|
+
if (!projectPath) {
|
|
28
|
+
return NextResponse.json(
|
|
29
|
+
{ error: 'projectPath is required' },
|
|
30
|
+
{ status: 400 }
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
switch (action) {
|
|
36
|
+
case 'status': {
|
|
37
|
+
const status = await getGitStatus(projectPath);
|
|
38
|
+
return NextResponse.json(status);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
case 'log': {
|
|
42
|
+
const maxCount = parseInt(searchParams.get('maxCount') || '50', 10);
|
|
43
|
+
const log = await getGitLog(projectPath, maxCount);
|
|
44
|
+
return NextResponse.json({ commits: log });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
case 'branches': {
|
|
48
|
+
const branches = await getGitBranches(projectPath);
|
|
49
|
+
return NextResponse.json({ branches });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
case 'diff': {
|
|
53
|
+
const diff = await getGitDiff(projectPath, file || undefined, staged);
|
|
54
|
+
return NextResponse.json({ diff });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
case 'remotes': {
|
|
58
|
+
const remotes = await getRemotes(projectPath);
|
|
59
|
+
return NextResponse.json({ remotes });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
default:
|
|
63
|
+
return NextResponse.json(
|
|
64
|
+
{ error: 'Invalid action' },
|
|
65
|
+
{ status: 400 }
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
} catch (error) {
|
|
69
|
+
console.error('Git API error:', error);
|
|
70
|
+
return NextResponse.json(
|
|
71
|
+
{ error: 'Git operation failed' },
|
|
72
|
+
{ status: 500 }
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// POST - Perform git operations
|
|
78
|
+
export async function POST(request: NextRequest) {
|
|
79
|
+
try {
|
|
80
|
+
const body = await request.json();
|
|
81
|
+
const { action, projectPath, files, message, remote, branch } = body;
|
|
82
|
+
|
|
83
|
+
if (!projectPath) {
|
|
84
|
+
return NextResponse.json(
|
|
85
|
+
{ error: 'projectPath is required' },
|
|
86
|
+
{ status: 400 }
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
switch (action) {
|
|
91
|
+
case 'stage': {
|
|
92
|
+
if (!files || !Array.isArray(files) || files.length === 0) {
|
|
93
|
+
return NextResponse.json(
|
|
94
|
+
{ error: 'files array is required' },
|
|
95
|
+
{ status: 400 }
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
const success = await gitStage(projectPath, files);
|
|
99
|
+
return NextResponse.json({ success });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
case 'unstage': {
|
|
103
|
+
if (!files || !Array.isArray(files) || files.length === 0) {
|
|
104
|
+
return NextResponse.json(
|
|
105
|
+
{ error: 'files array is required' },
|
|
106
|
+
{ status: 400 }
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
const success = await gitUnstage(projectPath, files);
|
|
110
|
+
return NextResponse.json({ success });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
case 'commit': {
|
|
114
|
+
if (!message) {
|
|
115
|
+
return NextResponse.json(
|
|
116
|
+
{ error: 'message is required' },
|
|
117
|
+
{ status: 400 }
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
const result = await gitCommit(projectPath, message);
|
|
121
|
+
return NextResponse.json(result);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
case 'push': {
|
|
125
|
+
const result = await gitPush(projectPath, remote || 'origin', branch);
|
|
126
|
+
return NextResponse.json(result);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
case 'pull': {
|
|
130
|
+
const result = await gitPull(projectPath, remote || 'origin', branch);
|
|
131
|
+
return NextResponse.json(result);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
case 'checkout': {
|
|
135
|
+
if (!branch) {
|
|
136
|
+
return NextResponse.json(
|
|
137
|
+
{ error: 'branch is required' },
|
|
138
|
+
{ status: 400 }
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
const result = await gitCheckout(projectPath, branch);
|
|
142
|
+
return NextResponse.json(result);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
case 'createBranch': {
|
|
146
|
+
if (!branch) {
|
|
147
|
+
return NextResponse.json(
|
|
148
|
+
{ error: 'branch is required' },
|
|
149
|
+
{ status: 400 }
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
const checkout = body.checkout !== false;
|
|
153
|
+
const result = await gitCreateBranch(projectPath, branch, checkout);
|
|
154
|
+
return NextResponse.json(result);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
case 'discard': {
|
|
158
|
+
if (!files || !Array.isArray(files) || files.length === 0) {
|
|
159
|
+
return NextResponse.json(
|
|
160
|
+
{ error: 'files array is required' },
|
|
161
|
+
{ status: 400 }
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
const success = await gitDiscard(projectPath, files);
|
|
165
|
+
return NextResponse.json({ success });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
case 'init': {
|
|
169
|
+
const result = await gitInit(projectPath);
|
|
170
|
+
return NextResponse.json(result);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
case 'stageAll': {
|
|
174
|
+
const success = await gitStage(projectPath, ['.']);
|
|
175
|
+
return NextResponse.json({ success });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
case 'unstageAll': {
|
|
179
|
+
const status = await getGitStatus(projectPath);
|
|
180
|
+
const stagedFiles = status.staged.map(f => f.path);
|
|
181
|
+
if (stagedFiles.length > 0) {
|
|
182
|
+
const success = await gitUnstage(projectPath, stagedFiles);
|
|
183
|
+
return NextResponse.json({ success });
|
|
184
|
+
}
|
|
185
|
+
return NextResponse.json({ success: true });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
default:
|
|
189
|
+
return NextResponse.json(
|
|
190
|
+
{ error: 'Invalid action' },
|
|
191
|
+
{ status: 400 }
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
} catch (error) {
|
|
195
|
+
console.error('Git API error:', error);
|
|
196
|
+
return NextResponse.json(
|
|
197
|
+
{ error: 'Git operation failed' },
|
|
198
|
+
{ status: 500 }
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { exec } from 'child_process';
|
|
3
|
+
import { promisify } from 'util';
|
|
4
|
+
import fs from 'fs/promises';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
|
|
7
|
+
const execAsync = promisify(exec);
|
|
8
|
+
|
|
9
|
+
interface HealthStatus {
|
|
10
|
+
claudeCli: {
|
|
11
|
+
installed: boolean;
|
|
12
|
+
authenticated: boolean;
|
|
13
|
+
version?: string;
|
|
14
|
+
error?: string;
|
|
15
|
+
};
|
|
16
|
+
project: {
|
|
17
|
+
valid: boolean;
|
|
18
|
+
hasDevflow: boolean;
|
|
19
|
+
hasClaudeProject: boolean;
|
|
20
|
+
};
|
|
21
|
+
system: {
|
|
22
|
+
platform: string;
|
|
23
|
+
nodeVersion: string;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* GET /api/health?projectPath=/path
|
|
29
|
+
* Check system health status
|
|
30
|
+
*/
|
|
31
|
+
export async function GET(req: NextRequest) {
|
|
32
|
+
const { searchParams } = new URL(req.url);
|
|
33
|
+
const projectPath = searchParams.get('projectPath');
|
|
34
|
+
|
|
35
|
+
const status: HealthStatus = {
|
|
36
|
+
claudeCli: {
|
|
37
|
+
installed: false,
|
|
38
|
+
authenticated: false,
|
|
39
|
+
},
|
|
40
|
+
project: {
|
|
41
|
+
valid: false,
|
|
42
|
+
hasDevflow: false,
|
|
43
|
+
hasClaudeProject: false,
|
|
44
|
+
},
|
|
45
|
+
system: {
|
|
46
|
+
platform: process.platform,
|
|
47
|
+
nodeVersion: process.version,
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// Check Claude CLI (skip whoami to avoid slowness)
|
|
52
|
+
try {
|
|
53
|
+
const { stdout } = await execAsync('claude --version', { timeout: 5000 });
|
|
54
|
+
status.claudeCli.installed = true;
|
|
55
|
+
status.claudeCli.version = stdout.trim();
|
|
56
|
+
status.claudeCli.authenticated = true; // Assume authenticated if CLI is installed
|
|
57
|
+
} catch (error) {
|
|
58
|
+
status.claudeCli.error = 'Claude CLI not found. Install from https://claude.ai/cli';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Check project if path provided
|
|
62
|
+
if (projectPath && !projectPath.includes('..')) {
|
|
63
|
+
try {
|
|
64
|
+
await fs.access(projectPath);
|
|
65
|
+
status.project.valid = true;
|
|
66
|
+
|
|
67
|
+
// Check .devflow in current path or parent directory
|
|
68
|
+
try {
|
|
69
|
+
await fs.access(path.join(projectPath, '.devflow'));
|
|
70
|
+
status.project.hasDevflow = true;
|
|
71
|
+
} catch {
|
|
72
|
+
// Try parent directory
|
|
73
|
+
try {
|
|
74
|
+
await fs.access(path.join(projectPath, '..', '.devflow'));
|
|
75
|
+
status.project.hasDevflow = true;
|
|
76
|
+
} catch {}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Check .claude_project in current path or parent directory
|
|
80
|
+
try {
|
|
81
|
+
await fs.access(path.join(projectPath, '.claude_project'));
|
|
82
|
+
status.project.hasClaudeProject = true;
|
|
83
|
+
} catch {
|
|
84
|
+
// Try parent directory
|
|
85
|
+
try {
|
|
86
|
+
await fs.access(path.join(projectPath, '..', '.claude_project'));
|
|
87
|
+
status.project.hasClaudeProject = true;
|
|
88
|
+
} catch {}
|
|
89
|
+
}
|
|
90
|
+
} catch {}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return NextResponse.json(status);
|
|
94
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
interface ProjectInfo {
|
|
6
|
+
path: string;
|
|
7
|
+
name: string;
|
|
8
|
+
isValid: boolean;
|
|
9
|
+
hasDevflow: boolean;
|
|
10
|
+
hasClaudeProject: boolean;
|
|
11
|
+
stats: {
|
|
12
|
+
specs: number;
|
|
13
|
+
stories: number;
|
|
14
|
+
adrs: number;
|
|
15
|
+
agents: number;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* POST /api/project/open
|
|
21
|
+
* Opens a DevFlow project and returns its metadata
|
|
22
|
+
*/
|
|
23
|
+
export async function POST(req: NextRequest) {
|
|
24
|
+
try {
|
|
25
|
+
const { path: projectPath } = await req.json();
|
|
26
|
+
|
|
27
|
+
// Validate path
|
|
28
|
+
if (!projectPath || typeof projectPath !== 'string') {
|
|
29
|
+
return NextResponse.json(
|
|
30
|
+
{ error: 'Project path is required' },
|
|
31
|
+
{ status: 400 }
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Security: Check for path traversal
|
|
36
|
+
if (projectPath.includes('..')) {
|
|
37
|
+
return NextResponse.json(
|
|
38
|
+
{ error: 'Invalid path' },
|
|
39
|
+
{ status: 400 }
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Check if path exists
|
|
44
|
+
try {
|
|
45
|
+
await fs.access(projectPath);
|
|
46
|
+
} catch {
|
|
47
|
+
return NextResponse.json(
|
|
48
|
+
{ error: 'Path does not exist' },
|
|
49
|
+
{ status: 404 }
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Check if it's a directory
|
|
54
|
+
const stat = await fs.stat(projectPath);
|
|
55
|
+
if (!stat.isDirectory()) {
|
|
56
|
+
return NextResponse.json(
|
|
57
|
+
{ error: 'Path is not a directory' },
|
|
58
|
+
{ status: 400 }
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Check for DevFlow markers
|
|
63
|
+
let hasDevflow = false;
|
|
64
|
+
let hasClaudeProject = false;
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
await fs.access(path.join(projectPath, '.devflow'));
|
|
68
|
+
hasDevflow = true;
|
|
69
|
+
} catch {}
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
await fs.access(path.join(projectPath, '.claude_project'));
|
|
73
|
+
hasClaudeProject = true;
|
|
74
|
+
} catch {}
|
|
75
|
+
|
|
76
|
+
// Get stats
|
|
77
|
+
const stats = {
|
|
78
|
+
specs: 0,
|
|
79
|
+
stories: 0,
|
|
80
|
+
adrs: 0,
|
|
81
|
+
agents: 0,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// Count stories
|
|
85
|
+
try {
|
|
86
|
+
const storiesPath = path.join(projectPath, 'docs', 'planning', 'stories');
|
|
87
|
+
const stories = await fs.readdir(storiesPath);
|
|
88
|
+
stats.stories = stories.filter(f => f.endsWith('.md') && f !== '.gitkeep').length;
|
|
89
|
+
} catch {}
|
|
90
|
+
|
|
91
|
+
// Count ADRs
|
|
92
|
+
try {
|
|
93
|
+
const adrsPath = path.join(projectPath, 'docs', 'decisions');
|
|
94
|
+
const adrs = await fs.readdir(adrsPath);
|
|
95
|
+
stats.adrs = adrs.filter(f => f.endsWith('.md') && !f.startsWith('000-')).length;
|
|
96
|
+
} catch {}
|
|
97
|
+
|
|
98
|
+
// Count agents
|
|
99
|
+
try {
|
|
100
|
+
const agentsPath = path.join(projectPath, '.devflow', 'agents');
|
|
101
|
+
const agents = await fs.readdir(agentsPath);
|
|
102
|
+
stats.agents = agents.filter(f => f.endsWith('.md')).length;
|
|
103
|
+
} catch {}
|
|
104
|
+
|
|
105
|
+
// Count specs (planning/*.md)
|
|
106
|
+
try {
|
|
107
|
+
const planningPath = path.join(projectPath, 'docs', 'planning');
|
|
108
|
+
const files = await fs.readdir(planningPath);
|
|
109
|
+
stats.specs = files.filter(f => f.endsWith('.md')).length;
|
|
110
|
+
} catch {}
|
|
111
|
+
|
|
112
|
+
const projectName = path.basename(projectPath);
|
|
113
|
+
|
|
114
|
+
const project: ProjectInfo = {
|
|
115
|
+
path: projectPath,
|
|
116
|
+
name: projectName,
|
|
117
|
+
isValid: hasDevflow || hasClaudeProject,
|
|
118
|
+
hasDevflow,
|
|
119
|
+
hasClaudeProject,
|
|
120
|
+
stats,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
return NextResponse.json({
|
|
124
|
+
success: true,
|
|
125
|
+
project,
|
|
126
|
+
});
|
|
127
|
+
} catch (error) {
|
|
128
|
+
console.error('Error opening project:', error);
|
|
129
|
+
return NextResponse.json(
|
|
130
|
+
{ error: 'Failed to open project' },
|
|
131
|
+
{ status: 500 }
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { promises as fs } from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
interface SearchResult {
|
|
6
|
+
filePath: string;
|
|
7
|
+
relativePath: string;
|
|
8
|
+
fileName: string;
|
|
9
|
+
matches: {
|
|
10
|
+
line: number;
|
|
11
|
+
content: string;
|
|
12
|
+
matchStart: number;
|
|
13
|
+
matchEnd: number;
|
|
14
|
+
}[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface FileResult {
|
|
18
|
+
filePath: string;
|
|
19
|
+
relativePath: string;
|
|
20
|
+
fileName: string;
|
|
21
|
+
extension: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Directories to ignore
|
|
25
|
+
const IGNORE_DIRS = [
|
|
26
|
+
'node_modules',
|
|
27
|
+
'.git',
|
|
28
|
+
'.next',
|
|
29
|
+
'dist',
|
|
30
|
+
'build',
|
|
31
|
+
'.turbo',
|
|
32
|
+
'coverage',
|
|
33
|
+
'__pycache__',
|
|
34
|
+
'.cache',
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
// File extensions to search
|
|
38
|
+
const SEARCHABLE_EXTENSIONS = [
|
|
39
|
+
'.md',
|
|
40
|
+
'.txt',
|
|
41
|
+
'.ts',
|
|
42
|
+
'.tsx',
|
|
43
|
+
'.js',
|
|
44
|
+
'.jsx',
|
|
45
|
+
'.json',
|
|
46
|
+
'.yaml',
|
|
47
|
+
'.yml',
|
|
48
|
+
'.css',
|
|
49
|
+
'.scss',
|
|
50
|
+
'.html',
|
|
51
|
+
'.py',
|
|
52
|
+
'.go',
|
|
53
|
+
'.rs',
|
|
54
|
+
'.java',
|
|
55
|
+
'.c',
|
|
56
|
+
'.cpp',
|
|
57
|
+
'.h',
|
|
58
|
+
'.sh',
|
|
59
|
+
'.env',
|
|
60
|
+
'.toml',
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
async function getAllFiles(
|
|
64
|
+
dir: string,
|
|
65
|
+
basePath: string,
|
|
66
|
+
files: FileResult[] = []
|
|
67
|
+
): Promise<FileResult[]> {
|
|
68
|
+
try {
|
|
69
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
70
|
+
|
|
71
|
+
for (const entry of entries) {
|
|
72
|
+
const fullPath = path.join(dir, entry.name);
|
|
73
|
+
const relativePath = path.relative(basePath, fullPath);
|
|
74
|
+
|
|
75
|
+
if (entry.isDirectory()) {
|
|
76
|
+
if (!IGNORE_DIRS.includes(entry.name) && !entry.name.startsWith('.')) {
|
|
77
|
+
await getAllFiles(fullPath, basePath, files);
|
|
78
|
+
}
|
|
79
|
+
} else {
|
|
80
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
81
|
+
if (SEARCHABLE_EXTENSIONS.includes(ext) || entry.name.startsWith('.')) {
|
|
82
|
+
files.push({
|
|
83
|
+
filePath: fullPath,
|
|
84
|
+
relativePath,
|
|
85
|
+
fileName: entry.name,
|
|
86
|
+
extension: ext,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
} catch {
|
|
92
|
+
// Ignore permission errors
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return files;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function searchInFile(
|
|
99
|
+
file: FileResult,
|
|
100
|
+
query: string,
|
|
101
|
+
caseSensitive: boolean,
|
|
102
|
+
isRegex: boolean
|
|
103
|
+
): Promise<SearchResult | null> {
|
|
104
|
+
try {
|
|
105
|
+
const content = await fs.readFile(file.filePath, 'utf-8');
|
|
106
|
+
const lines = content.split('\n');
|
|
107
|
+
const matches: SearchResult['matches'] = [];
|
|
108
|
+
|
|
109
|
+
let searchPattern: RegExp;
|
|
110
|
+
try {
|
|
111
|
+
if (isRegex) {
|
|
112
|
+
searchPattern = new RegExp(query, caseSensitive ? 'g' : 'gi');
|
|
113
|
+
} else {
|
|
114
|
+
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
115
|
+
searchPattern = new RegExp(escapedQuery, caseSensitive ? 'g' : 'gi');
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
// Invalid regex, return null
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
for (let i = 0; i < lines.length; i++) {
|
|
123
|
+
const line = lines[i];
|
|
124
|
+
let match: RegExpExecArray | null;
|
|
125
|
+
searchPattern.lastIndex = 0;
|
|
126
|
+
|
|
127
|
+
while ((match = searchPattern.exec(line)) !== null) {
|
|
128
|
+
matches.push({
|
|
129
|
+
line: i + 1,
|
|
130
|
+
content: line.slice(0, 200), // Limit line length
|
|
131
|
+
matchStart: match.index,
|
|
132
|
+
matchEnd: match.index + match[0].length,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// Prevent infinite loop for zero-length matches
|
|
136
|
+
if (match[0].length === 0) break;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (matches.length > 0) {
|
|
141
|
+
return {
|
|
142
|
+
filePath: file.filePath,
|
|
143
|
+
relativePath: file.relativePath,
|
|
144
|
+
fileName: file.fileName,
|
|
145
|
+
matches: matches.slice(0, 50), // Limit matches per file
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return null;
|
|
150
|
+
} catch {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* GET /api/search - Search for files or content
|
|
157
|
+
*/
|
|
158
|
+
export async function GET(req: NextRequest) {
|
|
159
|
+
try {
|
|
160
|
+
const searchParams = req.nextUrl.searchParams;
|
|
161
|
+
const projectPath = searchParams.get('projectPath');
|
|
162
|
+
const query = searchParams.get('query');
|
|
163
|
+
const type = searchParams.get('type') || 'files'; // 'files' or 'content'
|
|
164
|
+
const caseSensitive = searchParams.get('caseSensitive') === 'true';
|
|
165
|
+
const isRegex = searchParams.get('regex') === 'true';
|
|
166
|
+
const limit = parseInt(searchParams.get('limit') || '100', 10);
|
|
167
|
+
|
|
168
|
+
if (!projectPath) {
|
|
169
|
+
return NextResponse.json(
|
|
170
|
+
{ error: 'projectPath is required' },
|
|
171
|
+
{ status: 400 }
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Security: Validate path
|
|
176
|
+
if (projectPath.includes('..')) {
|
|
177
|
+
return NextResponse.json({ error: 'Invalid path' }, { status: 400 });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Get all files
|
|
181
|
+
const allFiles = await getAllFiles(projectPath, projectPath);
|
|
182
|
+
|
|
183
|
+
if (type === 'files') {
|
|
184
|
+
// Quick Open - search file names
|
|
185
|
+
let results = allFiles;
|
|
186
|
+
|
|
187
|
+
if (query && query.trim()) {
|
|
188
|
+
const lowerQuery = query.toLowerCase();
|
|
189
|
+
results = allFiles
|
|
190
|
+
.filter((file) => {
|
|
191
|
+
const lowerPath = file.relativePath.toLowerCase();
|
|
192
|
+
const lowerName = file.fileName.toLowerCase();
|
|
193
|
+
return lowerPath.includes(lowerQuery) || lowerName.includes(lowerQuery);
|
|
194
|
+
})
|
|
195
|
+
.sort((a, b) => {
|
|
196
|
+
// Prioritize exact filename matches
|
|
197
|
+
const aNameMatch = a.fileName.toLowerCase().includes(lowerQuery);
|
|
198
|
+
const bNameMatch = b.fileName.toLowerCase().includes(lowerQuery);
|
|
199
|
+
if (aNameMatch && !bNameMatch) return -1;
|
|
200
|
+
if (!aNameMatch && bNameMatch) return 1;
|
|
201
|
+
|
|
202
|
+
// Then by path length (shorter = better)
|
|
203
|
+
return a.relativePath.length - b.relativePath.length;
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return NextResponse.json({
|
|
208
|
+
type: 'files',
|
|
209
|
+
results: results.slice(0, limit),
|
|
210
|
+
total: results.length,
|
|
211
|
+
});
|
|
212
|
+
} else if (type === 'content') {
|
|
213
|
+
// Global Search - search file contents
|
|
214
|
+
if (!query || !query.trim()) {
|
|
215
|
+
return NextResponse.json({
|
|
216
|
+
type: 'content',
|
|
217
|
+
results: [],
|
|
218
|
+
total: 0,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const searchPromises = allFiles.map((file) =>
|
|
223
|
+
searchInFile(file, query, caseSensitive, isRegex)
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
const searchResults = await Promise.all(searchPromises);
|
|
227
|
+
const validResults = searchResults.filter(
|
|
228
|
+
(result): result is SearchResult => result !== null
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
// Sort by number of matches
|
|
232
|
+
validResults.sort((a, b) => b.matches.length - a.matches.length);
|
|
233
|
+
|
|
234
|
+
return NextResponse.json({
|
|
235
|
+
type: 'content',
|
|
236
|
+
results: validResults.slice(0, limit),
|
|
237
|
+
total: validResults.length,
|
|
238
|
+
totalMatches: validResults.reduce((sum, r) => sum + r.matches.length, 0),
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return NextResponse.json({ error: 'Invalid type' }, { status: 400 });
|
|
243
|
+
} catch (error) {
|
|
244
|
+
console.error('Search error:', error);
|
|
245
|
+
return NextResponse.json({ error: 'Search failed' }, { status: 500 });
|
|
246
|
+
}
|
|
247
|
+
}
|