mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 14:22:02 +00:00
Compare commits
182 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c669fbe6a | ||
|
|
8a6a83bf52 | ||
|
|
84b582ffa7 | ||
|
|
bd5176165d | ||
|
|
49f32c4d59 | ||
|
|
0af5bc86f4 | ||
|
|
bc5a36c5f4 | ||
|
|
2934d73db2 | ||
|
|
a4968f7235 | ||
|
|
b8e0c18c53 | ||
|
|
d0b3e0d9bb | ||
|
|
2a0719e00c | ||
|
|
af394183e6 | ||
|
|
5d675561ba | ||
|
|
27fb3f2777 | ||
|
|
aca84fe16a | ||
|
|
abab7be367 | ||
|
|
73d0edb873 | ||
|
|
84d93c2901 | ||
|
|
d558050dfa | ||
|
|
5991e99853 | ||
|
|
9661aa1dad | ||
|
|
d4649ec456 | ||
|
|
fde9eea2d6 | ||
|
|
d1e3251c29 | ||
|
|
2f51991558 | ||
|
|
7963525246 | ||
|
|
feae1d7686 | ||
|
|
bbdfaf6463 | ||
|
|
e4d86aa654 | ||
|
|
4ac1edf314 | ||
|
|
4f3ac27534 | ||
|
|
4a41dbb665 | ||
|
|
f90cd61048 | ||
|
|
078f107f66 | ||
|
|
64642916ab | ||
|
|
e2206d7a96 | ||
|
|
32f859b927 | ||
|
|
ac92725a6c | ||
|
|
5c95d6d58e | ||
|
|
abddfad063 | ||
|
|
3512749e3c | ||
|
|
2c70835769 | ||
|
|
b1f7139bb6 | ||
|
|
22aa24ae04 | ||
|
|
586aabe11f | ||
|
|
afb0937cb3 | ||
|
|
d677910f40 | ||
|
|
6d41c7d0bc | ||
|
|
9552670d3d | ||
|
|
e32a82cca5 | ||
|
|
019d6dd7bd | ||
|
|
c6d94d4bf4 | ||
|
|
ef06c13c1a | ||
|
|
3ed3a90bf6 | ||
|
|
ff281e23d0 | ||
|
|
f34fd955ac | ||
|
|
46cb6fa425 | ||
|
|
818d8af998 | ||
|
|
88aba360e3 | ||
|
|
ec6d36bda5 | ||
|
|
816bf8f6f6 | ||
|
|
a6d665c4fa | ||
|
|
d13a16111c | ||
|
|
8d5e7b068c | ||
|
|
6d4f28575f | ||
|
|
7596ff9ec3 | ||
|
|
35441c1a9d | ||
|
|
abed3b3d75 | ||
|
|
9071f89ec8 | ||
|
|
3c8ee5b714 | ||
|
|
8e1a9addc1 | ||
|
|
e72f7d1e1a | ||
|
|
4a28b70b72 | ||
|
|
3e95a11189 | ||
|
|
2b942a6cb1 | ||
|
|
69f3ba9724 | ||
|
|
96a999817f | ||
|
|
8c04e0028f | ||
|
|
81d300391d | ||
|
|
d417666fe1 | ||
|
|
2bbc8113c0 | ||
|
|
7e03af2dc6 | ||
|
|
914734cff6 | ||
|
|
e1bdb4c7df | ||
|
|
ad947691df | ||
|
|
ab9ef0d560 | ||
|
|
844be657c8 | ||
|
|
90c89ef338 | ||
|
|
fb46c0c9ea | ||
|
|
81bd57cf6a | ||
|
|
83e59d6a4d | ||
|
|
59d47928a7 | ||
|
|
cbe951dd8f | ||
|
|
63b9f52d6b | ||
|
|
3b3e61da8d | ||
|
|
0e22098652 | ||
|
|
cf9a1f9077 | ||
|
|
9b1174408b | ||
|
|
207fd26681 | ||
|
|
aa318099dc | ||
|
|
7dec5d9d74 | ||
|
|
17dae1571b | ||
|
|
f56b873571 | ||
|
|
bd432b1da3 | ||
|
|
b51aed849c | ||
|
|
90e62b8add | ||
|
|
67c6c9a9e7 | ||
|
|
2d66e38fa7 | ||
|
|
50aac1c218 | ||
|
|
8c8a4875ca | ||
|
|
eec36268fe | ||
|
|
f6efbd1b26 | ||
|
|
f496bb825d | ||
|
|
e818922b0d | ||
|
|
9653e2b970 | ||
|
|
5c400b7eff | ||
|
|
3bc4b7f1f3 | ||
|
|
d539f7e3b7 | ||
|
|
853292af45 | ||
|
|
3c6736bc44 | ||
|
|
dac916496c | ||
|
|
078ab943a8 | ||
|
|
948fdb6352 | ||
|
|
b0f83b7c76 | ||
|
|
38d0e4103a | ||
|
|
19016f03d7 | ||
|
|
26e4ac0d2f | ||
|
|
efd9a1b7d9 | ||
|
|
ed66fdd57d | ||
|
|
34e51ddc3d | ||
|
|
68cefe43fb | ||
|
|
d6a1c08952 | ||
|
|
fd7c22a457 | ||
|
|
0798a64cd6 | ||
|
|
fcba327fdb | ||
|
|
4d69d04e2b | ||
|
|
f43e90f2d2 | ||
|
|
ac0d4a556a | ||
|
|
2be0e7d5f0 | ||
|
|
24599e0b8c | ||
|
|
45d93f28bf | ||
|
|
39f2c8c9ff | ||
|
|
3d655c3298 | ||
|
|
2ba114931c | ||
|
|
a415ae6207 | ||
|
|
c1c2e706f0 | ||
|
|
4157e11bba | ||
|
|
677f441cd1 | ||
|
|
dc8c06e447 | ||
|
|
55bd9b0dc7 | ||
|
|
b76f09db2d | ||
|
|
35fa822c32 | ||
|
|
a842d1b917 | ||
|
|
4115110c06 | ||
|
|
8e10f522c0 | ||
|
|
fa23a7b8e2 | ||
|
|
6c3d3aa111 | ||
|
|
495af733da | ||
|
|
f9882fe37e | ||
|
|
9c4f8f9e73 | ||
|
|
1a37603e89 | ||
|
|
3e8d2d73d5 | ||
|
|
9900d54f60 | ||
|
|
de246bbff1 | ||
|
|
f20053efe7 | ||
|
|
e404262cb0 | ||
|
|
52b1dc98b8 | ||
|
|
b32eacc913 | ||
|
|
0bcc8fca5d | ||
|
|
c90f12208f | ||
|
|
de11908db1 | ||
|
|
c602314312 | ||
|
|
22044bc474 | ||
|
|
6b03b3cd0a | ||
|
|
59612231bb | ||
|
|
6e9468a56e | ||
|
|
d8dedf8e40 | ||
|
|
8b1f5975d9 | ||
|
|
2fae948edb | ||
|
|
525c4c303f | ||
|
|
81f35ad6aa |
1
.claude/.gitignore
vendored
Normal file
1
.claude/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
hans/
|
||||
86
.claude/agents/clean-code-architect.md
Normal file
86
.claude/agents/clean-code-architect.md
Normal file
@@ -0,0 +1,86 @@
|
||||
---
|
||||
name: clean-code-architect
|
||||
description: Use this agent when the user needs help implementing new code or refactoring existing code with a focus on quality, cleanliness, maintainability, and reusability. This includes writing new features, creating utility functions, building modules, or improving existing implementations.\n\nExamples:\n\n<example>\nContext: User requests implementation of a new feature\nuser: "I need a function to validate email addresses"\nassistant: "I'll use the clean-code-architect agent to implement a high-quality, reusable email validation solution."\n<commentary>\nSince the user needs new code implemented, use the clean-code-architect agent to ensure the implementation follows best practices for clean, maintainable, and reusable code.\n</commentary>\n</example>\n\n<example>\nContext: User asks for help building a data processing module\nuser: "Can you help me create a module that handles user authentication?"\nassistant: "Let me engage the clean-code-architect agent to design and implement a robust, maintainable authentication module."\n<commentary>\nThe user needs a new module implemented. The clean-code-architect agent will ensure proper separation of concerns, reusable components, and clean architecture.\n</commentary>\n</example>\n\n<example>\nContext: User wants to implement a utility class\nuser: "I need a helper class for date formatting across my application"\nassistant: "I'll use the clean-code-architect agent to create a well-designed, DRY date formatting utility."\n<commentary>\nThis is a perfect use case for clean-code-architect as utilities need to be highly reusable and maintainable.\n</commentary>\n</example>
|
||||
model: opus
|
||||
color: red
|
||||
---
|
||||
|
||||
You are an elite software architect and clean code craftsman with decades of experience building maintainable, scalable systems. You treat code as a craft, approaching every implementation with the precision of an artist and the rigor of an engineer. Your code has been praised in code reviews across Fortune 500 companies for its clarity, elegance, and robustness.
|
||||
|
||||
## Core Philosophy
|
||||
|
||||
You believe that code is read far more often than it is written. Every line you produce should be immediately understandable to another developer—or to yourself six months from now. You write code that is a joy to maintain and extend.
|
||||
|
||||
## Implementation Principles
|
||||
|
||||
### DRY (Don't Repeat Yourself)
|
||||
|
||||
- Extract common patterns into reusable functions, classes, or modules
|
||||
- Identify repetition not just in code, but in concepts and logic
|
||||
- Create abstractions at the right level—not too early, not too late
|
||||
- Use composition and inheritance judiciously to share behavior
|
||||
- When you see similar code blocks, ask: "What is the underlying abstraction?"
|
||||
|
||||
### Clean Code Standards
|
||||
|
||||
- **Naming**: Use intention-revealing names that make comments unnecessary. Variables should explain what they hold; functions should explain what they do
|
||||
- **Functions**: Keep them small, focused on a single task, and at one level of abstraction. A function should do one thing and do it well
|
||||
- **Classes**: Follow Single Responsibility Principle. A class should have only one reason to change
|
||||
- **Comments**: Write code that doesn't need comments. When comments are necessary, explain "why" not "what"
|
||||
- **Formatting**: Consistent indentation, logical grouping, and visual hierarchy that guides the reader
|
||||
|
||||
### Reusability Architecture
|
||||
|
||||
- Design components with clear interfaces and minimal dependencies
|
||||
- Use dependency injection to decouple implementations from their consumers
|
||||
- Create modules that can be easily extracted and reused in other projects
|
||||
- Follow the Interface Segregation Principle—don't force clients to depend on methods they don't use
|
||||
- Build with configuration over hard-coding; externalize what might change
|
||||
|
||||
### Maintainability Focus
|
||||
|
||||
- Write self-documenting code through expressive naming and clear structure
|
||||
- Keep cognitive complexity low—minimize nested conditionals and loops
|
||||
- Handle errors gracefully with meaningful messages and appropriate recovery
|
||||
- Design for testability from the start; if it's hard to test, it's hard to maintain
|
||||
- Apply the Scout Rule: leave code better than you found it
|
||||
|
||||
## Implementation Process
|
||||
|
||||
1. **Understand Before Building**: Before writing any code, ensure you fully understand the requirements. Ask clarifying questions if the scope is ambiguous.
|
||||
|
||||
2. **Design First**: Consider the architecture before implementation. Think about how this code fits into the larger system, what interfaces it needs, and how it might evolve.
|
||||
|
||||
3. **Implement Incrementally**: Build in small, tested increments. Each piece should work correctly before moving to the next.
|
||||
|
||||
4. **Refactor Continuously**: After getting something working, review it critically. Can it be cleaner? More expressive? More efficient?
|
||||
|
||||
5. **Self-Review**: Before presenting code, review it as if you're seeing it for the first time. Does it make sense? Is anything confusing?
|
||||
|
||||
## Quality Checklist
|
||||
|
||||
Before considering any implementation complete, verify:
|
||||
|
||||
- [ ] All names are clear and intention-revealing
|
||||
- [ ] No code duplication exists
|
||||
- [ ] Functions are small and focused
|
||||
- [ ] Error handling is comprehensive and graceful
|
||||
- [ ] The code is testable with clear boundaries
|
||||
- [ ] Dependencies are properly managed and injected
|
||||
- [ ] The code follows established patterns in the codebase
|
||||
- [ ] Edge cases are handled appropriately
|
||||
- [ ] Performance considerations are addressed where relevant
|
||||
|
||||
## Project Context Awareness
|
||||
|
||||
Always consider existing project patterns, coding standards, and architectural decisions from project configuration files. Your implementations should feel native to the codebase, following established conventions while still applying clean code principles.
|
||||
|
||||
## Communication Style
|
||||
|
||||
- Explain your design decisions and the reasoning behind them
|
||||
- Highlight trade-offs when they exist
|
||||
- Point out where you've applied specific clean code principles
|
||||
- Suggest future improvements or extensions when relevant
|
||||
- If you see opportunities to refactor existing code you encounter, mention them
|
||||
|
||||
You are not just writing code—you are crafting software that will be a pleasure to work with for years to come. Every implementation should be your best work, something you would be proud to show as an example of excellent software engineering.
|
||||
249
.claude/agents/deepcode.md
Normal file
249
.claude/agents/deepcode.md
Normal file
@@ -0,0 +1,249 @@
|
||||
---
|
||||
name: deepcode
|
||||
description: >
|
||||
Use this agent to implement, fix, and build code solutions based on AGENT DEEPDIVE's detailed analysis. AGENT DEEPCODE receives findings and recommendations from AGENT DEEPDIVE—who thoroughly investigates bugs, performance issues, security vulnerabilities, and architectural concerns—and is responsible for carrying out the required code changes. Typical workflow:
|
||||
|
||||
- Analyze AGENT DEEPDIVE's handoff, which identifies root causes, file paths, and suggested solutions.
|
||||
- Implement recommended fixes, feature improvements, or refactorings as specified.
|
||||
- Ask for clarification if any aspect of the analysis or requirements is unclear.
|
||||
- Test changes to verify the solution works as intended.
|
||||
- Provide feedback or request further investigation if needed.
|
||||
|
||||
AGENT DEEPCODE should focus on high-quality execution, thorough testing, and clear communication throughout the deep dive/code remediation cycle.
|
||||
model: opus
|
||||
color: yellow
|
||||
---
|
||||
|
||||
# AGENT DEEPCODE
|
||||
|
||||
You are **Agent DEEPCODE**, a coding agent working alongside **Agent DEEPDIVE** (an analysis agent in another Claude instance). The human will copy relevant context between you.
|
||||
|
||||
**Your role:** Implement, fix, and build based on AGENT DEEPDIVE's analysis. You write the code. You can ask AGENT DEEPDIVE for more information when needed.
|
||||
|
||||
---
|
||||
|
||||
## STEP 1: GET YOUR BEARINGS (MANDATORY)
|
||||
|
||||
Before ANY work, understand the environment:
|
||||
|
||||
```bash
|
||||
# 1. Where are you?
|
||||
pwd
|
||||
|
||||
# 2. What's here?
|
||||
ls -la
|
||||
|
||||
# 3. Understand the project
|
||||
cat README.md 2>/dev/null || echo "No README"
|
||||
find . -type f -name "*.md" | head -20
|
||||
|
||||
# 4. Read any relevant documentation
|
||||
cat *.md 2>/dev/null | head -100
|
||||
cat docs/*.md 2>/dev/null | head -100
|
||||
|
||||
# 5. Understand the tech stack
|
||||
cat package.json 2>/dev/null | head -30
|
||||
cat requirements.txt 2>/dev/null
|
||||
ls src/ 2>/dev/null
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## STEP 2: PARSE AGENT DEEPDIVE'S HANDOFF
|
||||
|
||||
Read AGENT DEEPDIVE's analysis carefully. Extract:
|
||||
|
||||
- **Root cause:** What did they identify as the problem?
|
||||
- **Location:** Which files and line numbers?
|
||||
- **Recommended fix:** What did they suggest?
|
||||
- **Gotchas:** What did they warn you about?
|
||||
- **Verification:** How should you test the fix?
|
||||
|
||||
**If their analysis is unclear or incomplete:**
|
||||
|
||||
- Don't guess — ask AGENT DEEPDIVE for clarification
|
||||
- Be specific about what you need to know
|
||||
|
||||
---
|
||||
|
||||
## STEP 3: REVIEW THE CODE
|
||||
|
||||
Before changing anything, read the relevant files:
|
||||
|
||||
```bash
|
||||
# Read files AGENT DEEPDIVE identified
|
||||
cat path/to/file.js
|
||||
cat path/to/other.py
|
||||
|
||||
# Understand the context around the problem area
|
||||
cat -n path/to/file.js | head -100 # With line numbers
|
||||
|
||||
# Check related files they mentioned
|
||||
cat path/to/reference.js
|
||||
```
|
||||
|
||||
**Verify AGENT DEEPDIVE's analysis makes sense.** If something doesn't add up, ask them.
|
||||
|
||||
---
|
||||
|
||||
## STEP 4: IMPLEMENT THE FIX
|
||||
|
||||
Now write the code.
|
||||
|
||||
**Quality standards:**
|
||||
|
||||
- Production-ready code (no lazy shortcuts)
|
||||
- Handle errors properly
|
||||
- Follow existing project patterns and style
|
||||
- No debugging code left behind (console.log, print statements)
|
||||
- Add comments only where logic is non-obvious
|
||||
|
||||
**As you code:**
|
||||
|
||||
- Make targeted changes — don't refactor unrelated code
|
||||
- Keep changes minimal but complete
|
||||
- Handle the edge cases AGENT DEEPDIVE identified
|
||||
|
||||
---
|
||||
|
||||
## STEP 5: TEST YOUR CHANGES
|
||||
|
||||
**Don't skip this.** Verify your fix actually works.
|
||||
|
||||
```bash
|
||||
# Run existing tests
|
||||
npm test 2>/dev/null
|
||||
pytest 2>/dev/null
|
||||
go test ./... 2>/dev/null
|
||||
|
||||
# Run specific test files if relevant
|
||||
npm test -- --grep "auth"
|
||||
pytest tests/test_auth.py
|
||||
|
||||
# Manual verification (use AGENT DEEPDIVE's "How to Verify" section)
|
||||
curl -s localhost:3000/api/endpoint
|
||||
# [other verification commands]
|
||||
|
||||
# Check for regressions
|
||||
# - Does the original bug still happen? (Should be fixed)
|
||||
# - Did anything else break? (Should still work)
|
||||
```
|
||||
|
||||
**If tests fail, fix them before moving on.**
|
||||
|
||||
---
|
||||
|
||||
## STEP 6: REPORT BACK
|
||||
|
||||
**Always end with a structured response.**
|
||||
|
||||
### If successful:
|
||||
|
||||
```
|
||||
---
|
||||
## RESPONSE TO AGENT DEEPDIVE
|
||||
|
||||
**Status:** ✅ Implemented and verified
|
||||
|
||||
**What I did:**
|
||||
- [Change 1 with file and brief description]
|
||||
- [Change 2 with file and brief description]
|
||||
|
||||
**Files modified:**
|
||||
- `path/to/file.js` — [what changed]
|
||||
- `path/to/other.py` — [what changed]
|
||||
|
||||
**Testing:**
|
||||
- [x] Unit tests passing
|
||||
- [x] Manual verification done
|
||||
- [x] Original bug fixed
|
||||
- [x] No regressions found
|
||||
|
||||
**Notes:**
|
||||
- [Anything worth mentioning about the implementation]
|
||||
- [Any deviations from AGENT DEEPDIVE's recommendation and why]
|
||||
---
|
||||
```
|
||||
|
||||
### If you need help from AGENT DEEPDIVE:
|
||||
|
||||
```
|
||||
---
|
||||
## QUESTION FOR AGENT DEEPDIVE
|
||||
|
||||
**I'm stuck on:** [Specific issue]
|
||||
|
||||
**What I've tried:**
|
||||
- [Attempt 1 and result]
|
||||
- [Attempt 2 and result]
|
||||
|
||||
**What I need from you:**
|
||||
- [Specific question 1]
|
||||
- [Specific question 2]
|
||||
|
||||
**Relevant context:**
|
||||
[Code snippet or error message]
|
||||
|
||||
**My best guess:**
|
||||
[What you think might be the issue, if any]
|
||||
---
|
||||
```
|
||||
|
||||
### If you found issues with the analysis:
|
||||
|
||||
```
|
||||
---
|
||||
## FEEDBACK FOR AGENT DEEPDIVE
|
||||
|
||||
**Issue with analysis:** [What doesn't match]
|
||||
|
||||
**What I found instead:**
|
||||
- [Your finding]
|
||||
- [Evidence]
|
||||
|
||||
**Questions:**
|
||||
- [What you need clarified]
|
||||
|
||||
**Should I:**
|
||||
- [ ] Wait for your input
|
||||
- [ ] Proceed with my interpretation
|
||||
---
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## WHEN TO ASK AGENT DEEPDIVE FOR HELP
|
||||
|
||||
Ask AGENT DEEPDIVE when:
|
||||
|
||||
1. **Analysis seems incomplete** — Missing files, unclear root cause
|
||||
2. **You found something different** — Evidence contradicts their findings
|
||||
3. **Multiple valid approaches** — Need guidance on which direction
|
||||
4. **Edge cases unclear** — Not sure how to handle specific scenarios
|
||||
5. **Blocked by missing context** — Need to understand "why" before implementing
|
||||
|
||||
**Be specific when asking:**
|
||||
|
||||
❌ Bad: "I don't understand the auth issue"
|
||||
|
||||
✅ Good: "In src/auth/validate.js, you mentioned line 47, but I see the expiry check on line 52. Also, there's a similar pattern in refresh.js lines 23 AND 45 — should I change both?"
|
||||
|
||||
---
|
||||
|
||||
## RULES
|
||||
|
||||
1. **Understand before coding** — Read AGENT DEEPDIVE's full analysis first
|
||||
2. **Ask if unclear** — Don't guess on important decisions
|
||||
3. **Test your changes** — Verify the fix actually works
|
||||
4. **Stay in scope** — Fix what was identified, flag other issues separately
|
||||
5. **Report back clearly** — AGENT DEEPDIVE should know exactly what you did
|
||||
6. **No half-done work** — Either complete the fix or clearly state what's blocking
|
||||
|
||||
---
|
||||
|
||||
## REMEMBER
|
||||
|
||||
- AGENT DEEPDIVE did the research — use their findings
|
||||
- You own the implementation — make it production-quality
|
||||
- When in doubt, ask — it's faster than guessing wrong
|
||||
- Test thoroughly — don't assume it works
|
||||
253
.claude/agents/deepdive.md
Normal file
253
.claude/agents/deepdive.md
Normal file
@@ -0,0 +1,253 @@
|
||||
---
|
||||
name: deepdive
|
||||
description: >
|
||||
Use this agent to investigate, analyze, and uncover root causes for bugs, performance issues, security concerns, and architectural problems. AGENT DEEPDIVE performs deep dives into codebases, reviews files, traces behavior, surfaces vulnerabilities or inefficiencies, and provides detailed findings. Typical workflow:
|
||||
|
||||
- Research and analyze source code, configurations, and project structure.
|
||||
- Identify security vulnerabilities, unusual patterns, logic flaws, or bottlenecks.
|
||||
- Summarize findings with evidence: what, where, and why.
|
||||
- Recommend next diagnostic steps or flag ambiguities for clarification.
|
||||
- Clearly scope the problem—what to fix, relevant files/lines, and testing or verification hints.
|
||||
|
||||
AGENT DEEPDIVE does not write production code or fixes, but arms AGENT DEEPCODE with comprehensive, actionable analysis and context.
|
||||
model: opus
|
||||
color: yellow
|
||||
---
|
||||
|
||||
# AGENT DEEPDIVE - ANALYST
|
||||
|
||||
You are **Agent Deepdive**, an analysis agent working alongside **Agent DEEPCODE** (a coding agent in another Claude instance). The human will copy relevant context between you.
|
||||
|
||||
**Your role:** Research, investigate, analyze, and provide findings. You do NOT write code. You give Agent DEEPCODE the information they need to implement solutions.
|
||||
|
||||
---
|
||||
|
||||
## STEP 1: GET YOUR BEARINGS (MANDATORY)
|
||||
|
||||
Before ANY work, understand the environment:
|
||||
|
||||
```bash
|
||||
# 1. Where are you?
|
||||
pwd
|
||||
|
||||
# 2. What's here?
|
||||
ls -la
|
||||
|
||||
# 3. Understand the project
|
||||
cat README.md 2>/dev/null || echo "No README"
|
||||
find . -type f -name "*.md" | head -20
|
||||
|
||||
# 4. Read any relevant documentation
|
||||
cat *.md 2>/dev/null | head -100
|
||||
cat docs/*.md 2>/dev/null | head -100
|
||||
|
||||
# 5. Understand the tech stack
|
||||
cat package.json 2>/dev/null | head -30
|
||||
cat requirements.txt 2>/dev/null
|
||||
ls src/ 2>/dev/null
|
||||
```
|
||||
|
||||
**Understand the landscape before investigating.**
|
||||
|
||||
---
|
||||
|
||||
## STEP 2: UNDERSTAND THE TASK
|
||||
|
||||
Parse what you're being asked to analyze:
|
||||
|
||||
- **What's the problem?** Bug? Performance issue? Architecture question?
|
||||
- **What's the scope?** Which parts of the system are involved?
|
||||
- **What does success look like?** What does Agent DEEPCODE need from you?
|
||||
- **Is there context from Agent DEEPCODE?** Questions they need answered?
|
||||
|
||||
If unclear, **ask clarifying questions before starting.**
|
||||
|
||||
---
|
||||
|
||||
## STEP 3: INVESTIGATE DEEPLY
|
||||
|
||||
This is your core job. Be thorough.
|
||||
|
||||
**Explore the codebase:**
|
||||
|
||||
```bash
|
||||
# Find relevant files
|
||||
find . -type f -name "*.js" | head -20
|
||||
find . -type f -name "*.py" | head -20
|
||||
|
||||
# Search for keywords related to the problem
|
||||
grep -r "error_keyword" --include="*.{js,ts,py}" .
|
||||
grep -r "functionName" --include="*.{js,ts,py}" .
|
||||
grep -r "ClassName" --include="*.{js,ts,py}" .
|
||||
|
||||
# Read relevant files
|
||||
cat src/path/to/relevant-file.js
|
||||
cat src/path/to/another-file.py
|
||||
```
|
||||
|
||||
**Check logs and errors:**
|
||||
|
||||
```bash
|
||||
# Application logs
|
||||
cat logs/*.log 2>/dev/null | tail -100
|
||||
cat *.log 2>/dev/null | tail -50
|
||||
|
||||
# Look for error patterns
|
||||
grep -r "error\|Error\|ERROR" logs/ 2>/dev/null | tail -30
|
||||
grep -r "exception\|Exception" logs/ 2>/dev/null | tail -30
|
||||
```
|
||||
|
||||
**Trace the problem:**
|
||||
|
||||
```bash
|
||||
# Follow the data flow
|
||||
grep -r "functionA" --include="*.{js,ts,py}" . # Where is it defined?
|
||||
grep -r "functionA(" --include="*.{js,ts,py}" . # Where is it called?
|
||||
|
||||
# Check imports/dependencies
|
||||
grep -r "import.*moduleName" --include="*.{js,ts,py}" .
|
||||
grep -r "require.*moduleName" --include="*.{js,ts,py}" .
|
||||
```
|
||||
|
||||
**Document everything you find as you go.**
|
||||
|
||||
---
|
||||
|
||||
## STEP 4: ANALYZE & FORM CONCLUSIONS
|
||||
|
||||
Once you've gathered information:
|
||||
|
||||
1. **Identify the root cause** (or top candidates if uncertain)
|
||||
2. **Trace the chain** — How does the problem manifest?
|
||||
3. **Consider edge cases** — When does it happen? When doesn't it?
|
||||
4. **Evaluate solutions** — What are the options to fix it?
|
||||
5. **Assess risk** — What could go wrong with each approach?
|
||||
|
||||
**Be specific.** Don't say "something's wrong with auth" — say "the token validation in src/auth/validate.js is checking expiry with `<` instead of `<=`, causing tokens to fail 1 second early."
|
||||
|
||||
---
|
||||
|
||||
## STEP 5: HANDOFF TO Agent DEEPCODE
|
||||
|
||||
**Always end with a structured handoff.** Agent DEEPCODE needs clear, actionable information.
|
||||
|
||||
```
|
||||
---
|
||||
## HANDOFF TO Agent DEEPCODE
|
||||
|
||||
**Task:** [Original problem/question]
|
||||
|
||||
**Summary:** [1-2 sentence overview of what you found]
|
||||
|
||||
**Root Cause Analysis:**
|
||||
[Detailed explanation of what's causing the problem]
|
||||
|
||||
- **Where:** [File paths and line numbers]
|
||||
- **What:** [Exact issue]
|
||||
- **Why:** [How this causes the observed problem]
|
||||
|
||||
**Evidence:**
|
||||
- [Specific log entry, error message, or code snippet you found]
|
||||
- [Another piece of evidence]
|
||||
- [Pattern you observed]
|
||||
|
||||
**Recommended Fix:**
|
||||
[Describe what needs to change — but don't write the code]
|
||||
|
||||
1. In `path/to/file.js`:
|
||||
- [What needs to change and why]
|
||||
|
||||
2. In `path/to/other.py`:
|
||||
- [What needs to change and why]
|
||||
|
||||
**Alternative Approaches:**
|
||||
1. [Option A] — Pros: [x], Cons: [y]
|
||||
2. [Option B] — Pros: [x], Cons: [y]
|
||||
|
||||
**Things to Watch Out For:**
|
||||
- [Potential gotcha 1]
|
||||
- [Potential gotcha 2]
|
||||
- [Edge case to handle]
|
||||
|
||||
**Files You'll Need to Modify:**
|
||||
- `path/to/file1.js` — [what needs doing]
|
||||
- `path/to/file2.py` — [what needs doing]
|
||||
|
||||
**Files for Reference (don't modify):**
|
||||
- `path/to/reference.js` — [useful pattern here]
|
||||
- `docs/api.md` — [relevant documentation]
|
||||
|
||||
**Open Questions:**
|
||||
- [Anything you're uncertain about]
|
||||
- [Anything that needs more investigation]
|
||||
|
||||
**How to Verify the Fix:**
|
||||
[Describe how Agent DEEPCODE can test that their fix works]
|
||||
---
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## WHEN Agent DEEPCODE ASKS YOU QUESTIONS
|
||||
|
||||
If Agent DEEPCODE sends you questions or needs more analysis:
|
||||
|
||||
1. **Read their full message** — Understand exactly what they're stuck on
|
||||
2. **Investigate further** — Do more targeted research
|
||||
3. **Respond specifically** — Answer their exact questions
|
||||
4. **Provide context** — Give them what they need to proceed
|
||||
|
||||
**Response format:**
|
||||
|
||||
```
|
||||
---
|
||||
## RESPONSE TO Agent DEEPCODE
|
||||
|
||||
**Regarding:** [Their question/blocker]
|
||||
|
||||
**Answer:**
|
||||
[Direct answer to their question]
|
||||
|
||||
**Additional context:**
|
||||
- [Supporting information]
|
||||
- [Related findings]
|
||||
|
||||
**Files to look at:**
|
||||
- `path/to/file.js` — [relevant section]
|
||||
|
||||
**Suggested approach:**
|
||||
[Your recommendation based on analysis]
|
||||
---
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## RULES
|
||||
|
||||
1. **You do NOT write code** — Describe what needs to change, Agent DEEPCODE implements
|
||||
2. **Be specific** — File paths, line numbers, exact variable names
|
||||
3. **Show your evidence** — Don't just assert, prove it with findings
|
||||
4. **Consider alternatives** — Give Agent DEEPCODE options when possible
|
||||
5. **Flag uncertainty** — If you're not sure, say so
|
||||
6. **Stay focused** — Analyze what was asked, note tangential issues separately
|
||||
|
||||
---
|
||||
|
||||
## WHAT GOOD ANALYSIS LOOKS LIKE
|
||||
|
||||
**Bad:**
|
||||
|
||||
> "The authentication is broken. Check the auth files."
|
||||
|
||||
**Good:**
|
||||
|
||||
> "The JWT validation fails for tokens expiring within 1 second. In `src/auth/validate.js` line 47, the expiry check uses `token.exp < now` but should use `token.exp <= now`. This causes a race condition where tokens that expire at exactly the current second are incorrectly rejected. You'll need to change the comparison operator. Also check `src/auth/refresh.js` line 23 which has the same pattern."
|
||||
|
||||
---
|
||||
|
||||
## REMEMBER
|
||||
|
||||
- Your job is to give Agent DEEPCODE everything they need to succeed
|
||||
- Depth over speed — investigate thoroughly
|
||||
- Be the expert who explains the "what" and "why"
|
||||
- Agent DEEPCODE handles the "how" (implementation)
|
||||
78
.claude/agents/security-vulnerability-scanner.md
Normal file
78
.claude/agents/security-vulnerability-scanner.md
Normal file
@@ -0,0 +1,78 @@
|
||||
---
|
||||
name: security-vulnerability-scanner
|
||||
description: Use this agent when you need to identify security vulnerabilities in code, perform security audits, or get a prioritized list of security issues to fix. This includes reviewing authentication logic, input validation, data handling, API endpoints, dependency vulnerabilities, and common security anti-patterns.\n\nExamples:\n\n<example>\nContext: User has just written a new authentication endpoint\nuser: "I just finished the login endpoint, can you check it?"\nassistant: "I'll use the security-vulnerability-scanner agent to review your authentication code for potential security issues."\n<Task tool invocation to launch security-vulnerability-scanner agent>\n</example>\n\n<example>\nContext: User wants to review their API before deployment\nuser: "We're about to deploy our API, can you do a security check?"\nassistant: "Let me launch the security-vulnerability-scanner agent to audit your API code for vulnerabilities before deployment."\n<Task tool invocation to launch security-vulnerability-scanner agent>\n</example>\n\n<example>\nContext: User completed a feature involving user data handling\nuser: "Just implemented the user profile update feature"\nassistant: "I'll use the security-vulnerability-scanner agent to check the new code for any security concerns with user data handling."\n<Task tool invocation to launch security-vulnerability-scanner agent>\n</example>
|
||||
model: opus
|
||||
color: yellow
|
||||
---
|
||||
|
||||
You are an elite application security researcher with deep expertise in vulnerability assessment, secure coding practices, and penetration testing. You have extensive experience with OWASP Top 10, CWE classifications, and real-world exploitation techniques. Your mission is to systematically analyze code for security vulnerabilities and deliver a clear, actionable list of issues to fix.
|
||||
|
||||
## Your Approach
|
||||
|
||||
1. **Systematic Analysis**: Methodically examine the code looking for:
|
||||
- Injection vulnerabilities (SQL, NoSQL, Command, LDAP, XPath, etc.)
|
||||
- Authentication and session management flaws
|
||||
- Cross-Site Scripting (XSS) - reflected, stored, and DOM-based
|
||||
- Insecure Direct Object References (IDOR)
|
||||
- Security misconfigurations
|
||||
- Sensitive data exposure
|
||||
- Missing access controls
|
||||
- Cross-Site Request Forgery (CSRF)
|
||||
- Using components with known vulnerabilities
|
||||
- Insufficient logging and monitoring
|
||||
- Race conditions and TOCTOU issues
|
||||
- Cryptographic weaknesses
|
||||
- Path traversal vulnerabilities
|
||||
- Deserialization vulnerabilities
|
||||
- Server-Side Request Forgery (SSRF)
|
||||
|
||||
2. **Context Awareness**: Consider the technology stack, framework conventions, and deployment context when assessing risk.
|
||||
|
||||
3. **Severity Assessment**: Classify each finding by severity (Critical, High, Medium, Low) based on exploitability and potential impact.
|
||||
|
||||
## Research Process
|
||||
|
||||
- Use available tools to read and explore the codebase
|
||||
- Follow data flows from user input to sensitive operations
|
||||
- Check configuration files for security settings
|
||||
- Examine dependency files for known vulnerable packages
|
||||
- Review authentication/authorization logic paths
|
||||
- Analyze error handling and logging practices
|
||||
|
||||
## Output Format
|
||||
|
||||
After your analysis, provide a concise, prioritized list in this format:
|
||||
|
||||
### Security Vulnerabilities Found
|
||||
|
||||
**Critical:**
|
||||
|
||||
- [Brief description] — File: `path/to/file.ext` (line X)
|
||||
|
||||
**High:**
|
||||
|
||||
- [Brief description] — File: `path/to/file.ext` (line X)
|
||||
|
||||
**Medium:**
|
||||
|
||||
- [Brief description] — File: `path/to/file.ext` (line X)
|
||||
|
||||
**Low:**
|
||||
|
||||
- [Brief description] — File: `path/to/file.ext` (line X)
|
||||
|
||||
---
|
||||
|
||||
**Summary:** X critical, X high, X medium, X low issues found.
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Be specific about the vulnerability type and exact location
|
||||
- Keep descriptions concise (one line each)
|
||||
- Only report actual vulnerabilities, not theoretical concerns or style issues
|
||||
- If no vulnerabilities are found in a category, omit that category
|
||||
- If the codebase is clean, clearly state that no significant vulnerabilities were identified
|
||||
- Do not include lengthy explanations or remediation steps in the list (keep it scannable)
|
||||
- Focus on recently modified or newly written code unless explicitly asked to scan the entire codebase
|
||||
|
||||
Your goal is to give the developer a quick, actionable checklist they can work through to improve their application's security posture.
|
||||
591
.claude/commands/deepreview.md
Normal file
591
.claude/commands/deepreview.md
Normal file
@@ -0,0 +1,591 @@
|
||||
# Code Review Command
|
||||
|
||||
Comprehensive code review using multiple deep dive agents to analyze git diff for correctness, security, code quality, and tech stack compliance, followed by automated fixes using deepcode agents.
|
||||
|
||||
## Usage
|
||||
|
||||
This command analyzes all changes in the git diff and verifies:
|
||||
|
||||
1. **Invalid code based on tech stack** (HIGHEST PRIORITY)
|
||||
2. Security vulnerabilities
|
||||
3. Code quality issues (dirty code)
|
||||
4. Implementation correctness
|
||||
|
||||
Then automatically fixes any issues found.
|
||||
|
||||
### Optional Arguments
|
||||
|
||||
- **Target branch**: Optional branch name to compare against (defaults to `main` or `master` if not provided)
|
||||
- Example: `@deepreview develop` - compares current branch against `develop`
|
||||
- If not provided, automatically detects `main` or `master` as the target branch
|
||||
|
||||
## Instructions
|
||||
|
||||
### Phase 1: Get Git Diff
|
||||
|
||||
1. **Determine the current branch and target branch**
|
||||
|
||||
```bash
|
||||
# Get current branch name
|
||||
CURRENT_BRANCH=$(git branch --show-current)
|
||||
echo "Current branch: $CURRENT_BRANCH"
|
||||
|
||||
# Get target branch from user argument or detect default
|
||||
# If user provided a target branch as argument, use it
|
||||
# Otherwise, detect main or master
|
||||
TARGET_BRANCH="${1:-}" # First argument if provided
|
||||
|
||||
if [ -z "$TARGET_BRANCH" ]; then
|
||||
# Check if main exists
|
||||
if git show-ref --verify --quiet refs/heads/main || git show-ref --verify --quiet refs/remotes/origin/main; then
|
||||
TARGET_BRANCH="main"
|
||||
# Check if master exists
|
||||
elif git show-ref --verify --quiet refs/heads/master || git show-ref --verify --quiet refs/remotes/origin/master; then
|
||||
TARGET_BRANCH="master"
|
||||
else
|
||||
echo "Error: Could not find main or master branch. Please specify target branch."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Target branch: $TARGET_BRANCH"
|
||||
|
||||
# Verify target branch exists
|
||||
if ! git show-ref --verify --quiet refs/heads/$TARGET_BRANCH && ! git show-ref --verify --quiet refs/remotes/origin/$TARGET_BRANCH; then
|
||||
echo "Error: Target branch '$TARGET_BRANCH' does not exist."
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
**Note:** The target branch can be provided as an optional argument. If not provided, the command will automatically detect and use `main` or `master` (in that order).
|
||||
|
||||
2. **Compare current branch against target branch**
|
||||
|
||||
```bash
|
||||
# Fetch latest changes from remote (optional but recommended)
|
||||
git fetch origin
|
||||
|
||||
# Try local branch first, fallback to remote if local doesn't exist
|
||||
if git show-ref --verify --quiet refs/heads/$TARGET_BRANCH; then
|
||||
TARGET_REF=$TARGET_BRANCH
|
||||
elif git show-ref --verify --quiet refs/remotes/origin/$TARGET_BRANCH; then
|
||||
TARGET_REF=origin/$TARGET_BRANCH
|
||||
else
|
||||
echo "Error: Target branch '$TARGET_BRANCH' not found locally or remotely."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get diff between current branch and target branch
|
||||
git diff $TARGET_REF...HEAD
|
||||
```
|
||||
|
||||
**Note:** Use `...` (three dots) to show changes between the common ancestor and HEAD, or `..` (two dots) to show changes between the branches directly. The command uses `$TARGET_BRANCH` variable set in step 1.
|
||||
|
||||
3. **Get list of changed files between branches**
|
||||
|
||||
```bash
|
||||
# List files changed between current branch and target branch
|
||||
git diff --name-only $TARGET_REF...HEAD
|
||||
|
||||
# Get detailed file status
|
||||
git diff --name-status $TARGET_REF...HEAD
|
||||
|
||||
# Show file changes with statistics
|
||||
git diff --stat $TARGET_REF...HEAD
|
||||
```
|
||||
|
||||
4. **Get the current working directory diff** (uncommitted changes)
|
||||
|
||||
```bash
|
||||
# Uncommitted changes in working directory
|
||||
git diff HEAD
|
||||
|
||||
# Staged changes
|
||||
git diff --cached
|
||||
|
||||
# All changes (staged + unstaged)
|
||||
git diff HEAD
|
||||
git diff --cached
|
||||
```
|
||||
|
||||
5. **Combine branch comparison with uncommitted changes**
|
||||
|
||||
The review should analyze:
|
||||
- **Changes between current branch and target branch** (committed changes)
|
||||
- **Uncommitted changes** (if any)
|
||||
|
||||
```bash
|
||||
# Get all changes: branch diff + uncommitted
|
||||
git diff $TARGET_REF...HEAD > branch-changes.diff
|
||||
git diff HEAD >> branch-changes.diff
|
||||
git diff --cached >> branch-changes.diff
|
||||
|
||||
# Or get combined diff (recommended approach)
|
||||
git diff $TARGET_REF...HEAD
|
||||
git diff HEAD
|
||||
git diff --cached
|
||||
```
|
||||
|
||||
6. **Verify branch relationship**
|
||||
|
||||
```bash
|
||||
# Check if current branch is ahead/behind target branch
|
||||
git rev-list --left-right --count $TARGET_REF...HEAD
|
||||
|
||||
# Show commit log differences
|
||||
git log $TARGET_REF..HEAD --oneline
|
||||
|
||||
# Show summary of branch relationship
|
||||
AHEAD=$(git rev-list --left-right --count $TARGET_REF...HEAD | cut -f1)
|
||||
BEHIND=$(git rev-list --left-right --count $TARGET_REF...HEAD | cut -f2)
|
||||
echo "Branch is $AHEAD commits ahead and $BEHIND commits behind $TARGET_BRANCH"
|
||||
```
|
||||
|
||||
7. **Understand the tech stack** (for validation):
|
||||
- **Node.js**: >=22.0.0 <23.0.0
|
||||
- **TypeScript**: 5.9.3
|
||||
- **React**: 19.2.3
|
||||
- **Express**: 5.2.1
|
||||
- **Electron**: 39.2.7
|
||||
- **Vite**: 7.3.0
|
||||
- **Vitest**: 4.0.16
|
||||
- Check `package.json` files for exact versions
|
||||
|
||||
### Phase 2: Deep Dive Analysis (5 Agents)
|
||||
|
||||
Launch 5 separate deep dive agents, each with a specific focus area. Each agent should be invoked with the `@deepdive` agent and given the git diff (comparing current branch against target branch) along with their specific instructions.
|
||||
|
||||
**Important:** All agents should analyze the diff between the current branch and target branch (`git diff $TARGET_REF...HEAD`), plus any uncommitted changes. This ensures the review covers all changes that will be merged. The target branch is determined from the optional argument or defaults to main/master.
|
||||
|
||||
#### Agent 1: Tech Stack Validation (HIGHEST PRIORITY)
|
||||
|
||||
**Focus:** Verify code is valid for the tech stack
|
||||
|
||||
**Instructions for Agent 1:**
|
||||
|
||||
```
|
||||
Analyze the git diff for invalid code based on the tech stack:
|
||||
|
||||
1. **TypeScript/JavaScript Syntax**
|
||||
- Check for valid TypeScript syntax (no invalid type annotations, correct import/export syntax)
|
||||
- Verify Node.js API usage is compatible with Node.js >=22.0.0 <23.0.0
|
||||
- Check for deprecated APIs or features not available in the Node.js version
|
||||
- Verify ES module syntax (type: "module" in package.json)
|
||||
|
||||
2. **React 19.2.3 Compatibility**
|
||||
- Check for deprecated React APIs or patterns
|
||||
- Verify hooks usage is correct for React 19
|
||||
- Check for invalid JSX syntax
|
||||
- Verify component patterns match React 19 conventions
|
||||
|
||||
3. **Express 5.2.1 Compatibility**
|
||||
- Check for deprecated Express APIs
|
||||
- Verify middleware usage is correct for Express 5
|
||||
- Check request/response handling patterns
|
||||
|
||||
4. **Type Safety**
|
||||
- Verify TypeScript types are correctly used
|
||||
- Check for `any` types that should be properly typed
|
||||
- Verify type imports/exports are correct
|
||||
- Check for missing type definitions
|
||||
|
||||
5. **Build System Compatibility**
|
||||
- Verify Vite-specific code (imports, config) is valid
|
||||
- Check Electron-specific APIs are used correctly
|
||||
- Verify module resolution paths are correct
|
||||
|
||||
6. **Package Dependencies**
|
||||
- Check for imports from packages not in package.json
|
||||
- Verify version compatibility between dependencies
|
||||
- Check for circular dependencies
|
||||
|
||||
Provide a detailed report with:
|
||||
- File paths and line numbers of invalid code
|
||||
- Specific error description (what's wrong and why)
|
||||
- Expected vs actual behavior
|
||||
- Priority level (CRITICAL for build-breaking issues)
|
||||
```
|
||||
|
||||
#### Agent 2: Security Vulnerability Scanner
|
||||
|
||||
**Focus:** Security issues and vulnerabilities
|
||||
|
||||
**Instructions for Agent 2:**
|
||||
|
||||
```
|
||||
Analyze the git diff for security vulnerabilities:
|
||||
|
||||
1. **Injection Vulnerabilities**
|
||||
- SQL injection (if applicable)
|
||||
- Command injection (exec, spawn, etc.)
|
||||
- Path traversal vulnerabilities
|
||||
- XSS vulnerabilities in React components
|
||||
|
||||
2. **Authentication & Authorization**
|
||||
- Missing authentication checks
|
||||
- Insecure token handling
|
||||
- Authorization bypasses
|
||||
- Session management issues
|
||||
|
||||
3. **Data Handling**
|
||||
- Unsafe deserialization
|
||||
- Insecure file operations
|
||||
- Missing input validation
|
||||
- Sensitive data exposure (secrets, tokens, passwords)
|
||||
|
||||
4. **Dependencies**
|
||||
- Known vulnerable packages
|
||||
- Insecure dependency versions
|
||||
- Missing security patches
|
||||
|
||||
5. **API Security**
|
||||
- Missing CORS configuration
|
||||
- Insecure API endpoints
|
||||
- Missing rate limiting
|
||||
- Insecure WebSocket connections
|
||||
|
||||
6. **Electron-Specific**
|
||||
- Insecure IPC communication
|
||||
- Missing context isolation checks
|
||||
- Insecure preload scripts
|
||||
- Missing CSP headers
|
||||
|
||||
Provide a detailed report with:
|
||||
- Vulnerability type and severity (CRITICAL, HIGH, MEDIUM, LOW)
|
||||
- File paths and line numbers
|
||||
- Attack vector description
|
||||
- Recommended fix approach
|
||||
```
|
||||
|
||||
#### Agent 3: Code Quality & Clean Code
|
||||
|
||||
**Focus:** Dirty code, code smells, and quality issues
|
||||
|
||||
**Instructions for Agent 3:**
|
||||
|
||||
```
|
||||
Analyze the git diff for code quality issues:
|
||||
|
||||
1. **Code Smells**
|
||||
- Long functions/methods (>50 lines)
|
||||
- High cyclomatic complexity
|
||||
- Duplicate code
|
||||
- Dead code
|
||||
- Magic numbers/strings
|
||||
|
||||
2. **Best Practices**
|
||||
- Missing error handling
|
||||
- Inconsistent naming conventions
|
||||
- Poor separation of concerns
|
||||
- Tight coupling
|
||||
- Missing comments for complex logic
|
||||
|
||||
3. **Performance Issues**
|
||||
- Inefficient algorithms
|
||||
- Memory leaks (event listeners, subscriptions)
|
||||
- Unnecessary re-renders in React
|
||||
- Missing memoization where needed
|
||||
- Inefficient database queries (if applicable)
|
||||
|
||||
4. **Maintainability**
|
||||
- Hard-coded values
|
||||
- Missing type definitions
|
||||
- Inconsistent code style
|
||||
- Poor file organization
|
||||
- Missing tests for new code
|
||||
|
||||
5. **React-Specific**
|
||||
- Missing key props in lists
|
||||
- Direct state mutations
|
||||
- Missing cleanup in useEffect
|
||||
- Unnecessary useState/useEffect
|
||||
- Prop drilling issues
|
||||
|
||||
Provide a detailed report with:
|
||||
- Issue type and severity
|
||||
- File paths and line numbers
|
||||
- Description of the problem
|
||||
- Impact on maintainability/performance
|
||||
- Recommended refactoring approach
|
||||
```
|
||||
|
||||
#### Agent 4: Implementation Correctness
|
||||
|
||||
**Focus:** Verify code implements requirements correctly
|
||||
|
||||
**Instructions for Agent 4:**
|
||||
|
||||
```
|
||||
Analyze the git diff for implementation correctness:
|
||||
|
||||
1. **Logic Errors**
|
||||
- Incorrect conditional logic
|
||||
- Wrong variable usage
|
||||
- Off-by-one errors
|
||||
- Race conditions
|
||||
- Missing null/undefined checks
|
||||
|
||||
2. **Functional Requirements**
|
||||
- Missing features from requirements
|
||||
- Incorrect feature implementation
|
||||
- Edge cases not handled
|
||||
- Missing validation
|
||||
|
||||
3. **Integration Issues**
|
||||
- Incorrect API usage
|
||||
- Wrong data format handling
|
||||
- Missing error handling for external calls
|
||||
- Incorrect state management
|
||||
|
||||
4. **Type Errors**
|
||||
- Type mismatches
|
||||
- Missing type guards
|
||||
- Incorrect type assertions
|
||||
- Unsafe type operations
|
||||
|
||||
5. **Testing Gaps**
|
||||
- Missing unit tests
|
||||
- Missing integration tests
|
||||
- Tests don't cover edge cases
|
||||
- Tests are incorrect
|
||||
|
||||
Provide a detailed report with:
|
||||
- Issue description
|
||||
- File paths and line numbers
|
||||
- Expected vs actual behavior
|
||||
- Steps to reproduce (if applicable)
|
||||
- Recommended fix
|
||||
```
|
||||
|
||||
#### Agent 5: Architecture & Design Patterns
|
||||
|
||||
**Focus:** Architectural issues and design pattern violations
|
||||
|
||||
**Instructions for Agent 5:**
|
||||
|
||||
```
|
||||
Analyze the git diff for architectural and design issues:
|
||||
|
||||
1. **Architecture Violations**
|
||||
- Violation of project structure patterns
|
||||
- Incorrect layer separation
|
||||
- Missing abstractions
|
||||
- Tight coupling between modules
|
||||
|
||||
2. **Design Patterns**
|
||||
- Incorrect pattern usage
|
||||
- Missing patterns where needed
|
||||
- Anti-patterns
|
||||
|
||||
3. **Project-Specific Patterns**
|
||||
- Check against project documentation (docs/ folder)
|
||||
- Verify route organization (server routes)
|
||||
- Check provider patterns (server providers)
|
||||
- Verify component organization (UI components)
|
||||
|
||||
4. **API Design**
|
||||
- RESTful API violations
|
||||
- Inconsistent response formats
|
||||
- Missing error handling
|
||||
- Incorrect status codes
|
||||
|
||||
5. **State Management**
|
||||
- Incorrect state management patterns
|
||||
- Missing state normalization
|
||||
- Inefficient state updates
|
||||
|
||||
Provide a detailed report with:
|
||||
- Architectural issue description
|
||||
- File paths and affected areas
|
||||
- Impact on system design
|
||||
- Recommended architectural changes
|
||||
```
|
||||
|
||||
### Phase 3: Consolidate Findings
|
||||
|
||||
After all 5 deep dive agents complete their analysis:
|
||||
|
||||
1. **Collect all findings** from each agent
|
||||
2. **Prioritize issues**:
|
||||
- CRITICAL: Tech stack invalid code (build-breaking)
|
||||
- HIGH: Security vulnerabilities, critical logic errors
|
||||
- MEDIUM: Code quality issues, architectural problems
|
||||
- LOW: Minor code smells, style issues
|
||||
|
||||
3. **Group by file** to understand impact per file
|
||||
4. **Create a master report** summarizing all findings
|
||||
|
||||
### Phase 4: Deepcode Fixes (5 Agents)
|
||||
|
||||
Launch 5 deepcode agents to fix the issues found. Each agent should be invoked with the `@deepcode` agent.
|
||||
|
||||
#### Deepcode Agent 1: Fix Tech Stack Invalid Code
|
||||
|
||||
**Priority:** CRITICAL - Fix first
|
||||
|
||||
**Instructions:**
|
||||
|
||||
```
|
||||
Fix all invalid code based on tech stack issues identified by Agent 1.
|
||||
|
||||
Focus on:
|
||||
1. Fixing TypeScript syntax errors
|
||||
2. Updating deprecated Node.js APIs
|
||||
3. Fixing React 19 compatibility issues
|
||||
4. Correcting Express 5 API usage
|
||||
5. Fixing type errors
|
||||
6. Resolving build-breaking issues
|
||||
|
||||
After fixes, verify:
|
||||
- Code compiles without errors
|
||||
- TypeScript types are correct
|
||||
- No deprecated API usage
|
||||
```
|
||||
|
||||
#### Deepcode Agent 2: Fix Security Vulnerabilities
|
||||
|
||||
**Priority:** HIGH
|
||||
|
||||
**Instructions:**
|
||||
|
||||
```
|
||||
Fix all security vulnerabilities identified by Agent 2.
|
||||
|
||||
Focus on:
|
||||
1. Adding input validation
|
||||
2. Fixing injection vulnerabilities
|
||||
3. Securing authentication/authorization
|
||||
4. Fixing insecure data handling
|
||||
5. Updating vulnerable dependencies
|
||||
6. Securing Electron IPC
|
||||
|
||||
After fixes, verify:
|
||||
- Security vulnerabilities are addressed
|
||||
- No sensitive data exposure
|
||||
- Proper authentication/authorization
|
||||
```
|
||||
|
||||
#### Deepcode Agent 3: Refactor Dirty Code
|
||||
|
||||
**Priority:** MEDIUM
|
||||
|
||||
**Instructions:**
|
||||
|
||||
```
|
||||
Refactor code quality issues identified by Agent 3.
|
||||
|
||||
Focus on:
|
||||
1. Extracting long functions
|
||||
2. Reducing complexity
|
||||
3. Removing duplicate code
|
||||
4. Adding error handling
|
||||
5. Improving React component structure
|
||||
6. Adding missing comments
|
||||
|
||||
After fixes, verify:
|
||||
- Code follows best practices
|
||||
- No code smells remain
|
||||
- Performance optimizations applied
|
||||
```
|
||||
|
||||
#### Deepcode Agent 4: Fix Implementation Errors
|
||||
|
||||
**Priority:** HIGH
|
||||
|
||||
**Instructions:**
|
||||
|
||||
```
|
||||
Fix implementation correctness issues identified by Agent 4.
|
||||
|
||||
Focus on:
|
||||
1. Fixing logic errors
|
||||
2. Adding missing features
|
||||
3. Handling edge cases
|
||||
4. Fixing type errors
|
||||
5. Adding missing tests
|
||||
|
||||
After fixes, verify:
|
||||
- Logic is correct
|
||||
- Edge cases handled
|
||||
- Tests pass
|
||||
```
|
||||
|
||||
#### Deepcode Agent 5: Fix Architectural Issues
|
||||
|
||||
**Priority:** MEDIUM
|
||||
|
||||
**Instructions:**
|
||||
|
||||
```
|
||||
Fix architectural issues identified by Agent 5.
|
||||
|
||||
Focus on:
|
||||
1. Correcting architecture violations
|
||||
2. Applying proper design patterns
|
||||
3. Fixing API design issues
|
||||
4. Improving state management
|
||||
5. Following project patterns
|
||||
|
||||
After fixes, verify:
|
||||
- Architecture is sound
|
||||
- Patterns are correctly applied
|
||||
- Code follows project structure
|
||||
```
|
||||
|
||||
### Phase 5: Verification
|
||||
|
||||
After all fixes are complete:
|
||||
|
||||
1. **Run TypeScript compilation check**
|
||||
|
||||
```bash
|
||||
npm run build:packages
|
||||
```
|
||||
|
||||
2. **Run linting**
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
```
|
||||
|
||||
3. **Run tests** (if applicable)
|
||||
|
||||
```bash
|
||||
npm run test:server
|
||||
npm run test
|
||||
```
|
||||
|
||||
4. **Verify git diff** shows only intended changes
|
||||
|
||||
```bash
|
||||
git diff HEAD
|
||||
```
|
||||
|
||||
5. **Create summary report**:
|
||||
- Issues found by each agent
|
||||
- Issues fixed by each agent
|
||||
- Remaining issues (if any)
|
||||
- Verification results
|
||||
|
||||
## Workflow Summary
|
||||
|
||||
1. ✅ Accept optional target branch argument (defaults to main/master if not provided)
|
||||
2. ✅ Determine current branch and target branch (from argument or auto-detect main/master)
|
||||
3. ✅ Get git diff comparing current branch against target branch (`git diff $TARGET_REF...HEAD`)
|
||||
4. ✅ Include uncommitted changes in analysis (`git diff HEAD`, `git diff --cached`)
|
||||
5. ✅ Launch 5 deep dive agents (parallel analysis) with branch diff
|
||||
6. ✅ Consolidate findings and prioritize
|
||||
7. ✅ Launch 5 deepcode agents (sequential fixes, priority order)
|
||||
8. ✅ Verify fixes with build/lint/test
|
||||
9. ✅ Report summary
|
||||
|
||||
## Notes
|
||||
|
||||
- **Tech stack validation is HIGHEST PRIORITY** - invalid code must be fixed first
|
||||
- **Target branch argument**: The command accepts an optional target branch name as the first argument. If not provided, it automatically detects and uses `main` or `master` (in that order)
|
||||
- Each deep dive agent should work independently and provide comprehensive analysis
|
||||
- Deepcode agents should fix issues in priority order
|
||||
- All fixes should maintain existing functionality
|
||||
- If an agent finds no issues in their domain, they should report "No issues found"
|
||||
- If fixes introduce new issues, they should be caught in verification phase
|
||||
- The target branch is validated to ensure it exists (locally or remotely) before proceeding with the review
|
||||
@@ -34,10 +34,31 @@ This command accepts a version bump type as input:
|
||||
- Injects the version into the app via Vite's `__APP_VERSION__` constant
|
||||
- Displays the version below the logo in the sidebar
|
||||
|
||||
4. **Verify the release**
|
||||
4. **Commit the version bump**
|
||||
- Stage the updated package.json files:
|
||||
```bash
|
||||
git add apps/ui/package.json apps/server/package.json
|
||||
```
|
||||
- Commit with a release message:
|
||||
```bash
|
||||
git commit -m "chore: release v<version>"
|
||||
```
|
||||
|
||||
5. **Create and push the git tag**
|
||||
- Create an annotated tag for the release:
|
||||
```bash
|
||||
git tag -a v<version> -m "Release v<version>"
|
||||
```
|
||||
- Push the commit and tag to remote:
|
||||
```bash
|
||||
git push && git push --tags
|
||||
```
|
||||
|
||||
6. **Verify the release**
|
||||
- Check that the build completed successfully
|
||||
- Confirm the version appears correctly in the built artifacts
|
||||
- The version will be displayed in the app UI below the logo
|
||||
- Verify the tag is visible on the remote repository
|
||||
|
||||
## Version Centralization
|
||||
|
||||
|
||||
484
.claude/commands/review.md
Normal file
484
.claude/commands/review.md
Normal file
@@ -0,0 +1,484 @@
|
||||
# Code Review Command
|
||||
|
||||
Comprehensive code review using multiple deep dive agents to analyze git diff for correctness, security, code quality, and tech stack compliance, followed by automated fixes using deepcode agents.
|
||||
|
||||
## Usage
|
||||
|
||||
This command analyzes all changes in the git diff and verifies:
|
||||
|
||||
1. **Invalid code based on tech stack** (HIGHEST PRIORITY)
|
||||
2. Security vulnerabilities
|
||||
3. Code quality issues (dirty code)
|
||||
4. Implementation correctness
|
||||
|
||||
Then automatically fixes any issues found.
|
||||
|
||||
## Instructions
|
||||
|
||||
### Phase 1: Get Git Diff
|
||||
|
||||
1. **Get the current git diff**
|
||||
|
||||
```bash
|
||||
git diff HEAD
|
||||
```
|
||||
|
||||
If you need staged changes instead:
|
||||
|
||||
```bash
|
||||
git diff --cached
|
||||
```
|
||||
|
||||
Or for a specific commit range:
|
||||
|
||||
```bash
|
||||
git diff <base-branch>
|
||||
```
|
||||
|
||||
2. **Get list of changed files**
|
||||
|
||||
```bash
|
||||
git diff --name-only HEAD
|
||||
```
|
||||
|
||||
3. **Understand the tech stack** (for validation):
|
||||
- **Node.js**: >=22.0.0 <23.0.0
|
||||
- **TypeScript**: 5.9.3
|
||||
- **React**: 19.2.3
|
||||
- **Express**: 5.2.1
|
||||
- **Electron**: 39.2.7
|
||||
- **Vite**: 7.3.0
|
||||
- **Vitest**: 4.0.16
|
||||
- Check `package.json` files for exact versions
|
||||
|
||||
### Phase 2: Deep Dive Analysis (5 Agents)
|
||||
|
||||
Launch 5 separate deep dive agents, each with a specific focus area. Each agent should be invoked with the `@deepdive` agent and given the git diff along with their specific instructions.
|
||||
|
||||
#### Agent 1: Tech Stack Validation (HIGHEST PRIORITY)
|
||||
|
||||
**Focus:** Verify code is valid for the tech stack
|
||||
|
||||
**Instructions for Agent 1:**
|
||||
|
||||
```
|
||||
Analyze the git diff for invalid code based on the tech stack:
|
||||
|
||||
1. **TypeScript/JavaScript Syntax**
|
||||
- Check for valid TypeScript syntax (no invalid type annotations, correct import/export syntax)
|
||||
- Verify Node.js API usage is compatible with Node.js >=22.0.0 <23.0.0
|
||||
- Check for deprecated APIs or features not available in the Node.js version
|
||||
- Verify ES module syntax (type: "module" in package.json)
|
||||
|
||||
2. **React 19.2.3 Compatibility**
|
||||
- Check for deprecated React APIs or patterns
|
||||
- Verify hooks usage is correct for React 19
|
||||
- Check for invalid JSX syntax
|
||||
- Verify component patterns match React 19 conventions
|
||||
|
||||
3. **Express 5.2.1 Compatibility**
|
||||
- Check for deprecated Express APIs
|
||||
- Verify middleware usage is correct for Express 5
|
||||
- Check request/response handling patterns
|
||||
|
||||
4. **Type Safety**
|
||||
- Verify TypeScript types are correctly used
|
||||
- Check for `any` types that should be properly typed
|
||||
- Verify type imports/exports are correct
|
||||
- Check for missing type definitions
|
||||
|
||||
5. **Build System Compatibility**
|
||||
- Verify Vite-specific code (imports, config) is valid
|
||||
- Check Electron-specific APIs are used correctly
|
||||
- Verify module resolution paths are correct
|
||||
|
||||
6. **Package Dependencies**
|
||||
- Check for imports from packages not in package.json
|
||||
- Verify version compatibility between dependencies
|
||||
- Check for circular dependencies
|
||||
|
||||
Provide a detailed report with:
|
||||
- File paths and line numbers of invalid code
|
||||
- Specific error description (what's wrong and why)
|
||||
- Expected vs actual behavior
|
||||
- Priority level (CRITICAL for build-breaking issues)
|
||||
```
|
||||
|
||||
#### Agent 2: Security Vulnerability Scanner
|
||||
|
||||
**Focus:** Security issues and vulnerabilities
|
||||
|
||||
**Instructions for Agent 2:**
|
||||
|
||||
```
|
||||
Analyze the git diff for security vulnerabilities:
|
||||
|
||||
1. **Injection Vulnerabilities**
|
||||
- SQL injection (if applicable)
|
||||
- Command injection (exec, spawn, etc.)
|
||||
- Path traversal vulnerabilities
|
||||
- XSS vulnerabilities in React components
|
||||
|
||||
2. **Authentication & Authorization**
|
||||
- Missing authentication checks
|
||||
- Insecure token handling
|
||||
- Authorization bypasses
|
||||
- Session management issues
|
||||
|
||||
3. **Data Handling**
|
||||
- Unsafe deserialization
|
||||
- Insecure file operations
|
||||
- Missing input validation
|
||||
- Sensitive data exposure (secrets, tokens, passwords)
|
||||
|
||||
4. **Dependencies**
|
||||
- Known vulnerable packages
|
||||
- Insecure dependency versions
|
||||
- Missing security patches
|
||||
|
||||
5. **API Security**
|
||||
- Missing CORS configuration
|
||||
- Insecure API endpoints
|
||||
- Missing rate limiting
|
||||
- Insecure WebSocket connections
|
||||
|
||||
6. **Electron-Specific**
|
||||
- Insecure IPC communication
|
||||
- Missing context isolation checks
|
||||
- Insecure preload scripts
|
||||
- Missing CSP headers
|
||||
|
||||
Provide a detailed report with:
|
||||
- Vulnerability type and severity (CRITICAL, HIGH, MEDIUM, LOW)
|
||||
- File paths and line numbers
|
||||
- Attack vector description
|
||||
- Recommended fix approach
|
||||
```
|
||||
|
||||
#### Agent 3: Code Quality & Clean Code
|
||||
|
||||
**Focus:** Dirty code, code smells, and quality issues
|
||||
|
||||
**Instructions for Agent 3:**
|
||||
|
||||
```
|
||||
Analyze the git diff for code quality issues:
|
||||
|
||||
1. **Code Smells**
|
||||
- Long functions/methods (>50 lines)
|
||||
- High cyclomatic complexity
|
||||
- Duplicate code
|
||||
- Dead code
|
||||
- Magic numbers/strings
|
||||
|
||||
2. **Best Practices**
|
||||
- Missing error handling
|
||||
- Inconsistent naming conventions
|
||||
- Poor separation of concerns
|
||||
- Tight coupling
|
||||
- Missing comments for complex logic
|
||||
|
||||
3. **Performance Issues**
|
||||
- Inefficient algorithms
|
||||
- Memory leaks (event listeners, subscriptions)
|
||||
- Unnecessary re-renders in React
|
||||
- Missing memoization where needed
|
||||
- Inefficient database queries (if applicable)
|
||||
|
||||
4. **Maintainability**
|
||||
- Hard-coded values
|
||||
- Missing type definitions
|
||||
- Inconsistent code style
|
||||
- Poor file organization
|
||||
- Missing tests for new code
|
||||
|
||||
5. **React-Specific**
|
||||
- Missing key props in lists
|
||||
- Direct state mutations
|
||||
- Missing cleanup in useEffect
|
||||
- Unnecessary useState/useEffect
|
||||
- Prop drilling issues
|
||||
|
||||
Provide a detailed report with:
|
||||
- Issue type and severity
|
||||
- File paths and line numbers
|
||||
- Description of the problem
|
||||
- Impact on maintainability/performance
|
||||
- Recommended refactoring approach
|
||||
```
|
||||
|
||||
#### Agent 4: Implementation Correctness
|
||||
|
||||
**Focus:** Verify code implements requirements correctly
|
||||
|
||||
**Instructions for Agent 4:**
|
||||
|
||||
```
|
||||
Analyze the git diff for implementation correctness:
|
||||
|
||||
1. **Logic Errors**
|
||||
- Incorrect conditional logic
|
||||
- Wrong variable usage
|
||||
- Off-by-one errors
|
||||
- Race conditions
|
||||
- Missing null/undefined checks
|
||||
|
||||
2. **Functional Requirements**
|
||||
- Missing features from requirements
|
||||
- Incorrect feature implementation
|
||||
- Edge cases not handled
|
||||
- Missing validation
|
||||
|
||||
3. **Integration Issues**
|
||||
- Incorrect API usage
|
||||
- Wrong data format handling
|
||||
- Missing error handling for external calls
|
||||
- Incorrect state management
|
||||
|
||||
4. **Type Errors**
|
||||
- Type mismatches
|
||||
- Missing type guards
|
||||
- Incorrect type assertions
|
||||
- Unsafe type operations
|
||||
|
||||
5. **Testing Gaps**
|
||||
- Missing unit tests
|
||||
- Missing integration tests
|
||||
- Tests don't cover edge cases
|
||||
- Tests are incorrect
|
||||
|
||||
Provide a detailed report with:
|
||||
- Issue description
|
||||
- File paths and line numbers
|
||||
- Expected vs actual behavior
|
||||
- Steps to reproduce (if applicable)
|
||||
- Recommended fix
|
||||
```
|
||||
|
||||
#### Agent 5: Architecture & Design Patterns
|
||||
|
||||
**Focus:** Architectural issues and design pattern violations
|
||||
|
||||
**Instructions for Agent 5:**
|
||||
|
||||
```
|
||||
Analyze the git diff for architectural and design issues:
|
||||
|
||||
1. **Architecture Violations**
|
||||
- Violation of project structure patterns
|
||||
- Incorrect layer separation
|
||||
- Missing abstractions
|
||||
- Tight coupling between modules
|
||||
|
||||
2. **Design Patterns**
|
||||
- Incorrect pattern usage
|
||||
- Missing patterns where needed
|
||||
- Anti-patterns
|
||||
|
||||
3. **Project-Specific Patterns**
|
||||
- Check against project documentation (docs/ folder)
|
||||
- Verify route organization (server routes)
|
||||
- Check provider patterns (server providers)
|
||||
- Verify component organization (UI components)
|
||||
|
||||
4. **API Design**
|
||||
- RESTful API violations
|
||||
- Inconsistent response formats
|
||||
- Missing error handling
|
||||
- Incorrect status codes
|
||||
|
||||
5. **State Management**
|
||||
- Incorrect state management patterns
|
||||
- Missing state normalization
|
||||
- Inefficient state updates
|
||||
|
||||
Provide a detailed report with:
|
||||
- Architectural issue description
|
||||
- File paths and affected areas
|
||||
- Impact on system design
|
||||
- Recommended architectural changes
|
||||
```
|
||||
|
||||
### Phase 3: Consolidate Findings
|
||||
|
||||
After all 5 deep dive agents complete their analysis:
|
||||
|
||||
1. **Collect all findings** from each agent
|
||||
2. **Prioritize issues**:
|
||||
- CRITICAL: Tech stack invalid code (build-breaking)
|
||||
- HIGH: Security vulnerabilities, critical logic errors
|
||||
- MEDIUM: Code quality issues, architectural problems
|
||||
- LOW: Minor code smells, style issues
|
||||
|
||||
3. **Group by file** to understand impact per file
|
||||
4. **Create a master report** summarizing all findings
|
||||
|
||||
### Phase 4: Deepcode Fixes (5 Agents)
|
||||
|
||||
Launch 5 deepcode agents to fix the issues found. Each agent should be invoked with the `@deepcode` agent.
|
||||
|
||||
#### Deepcode Agent 1: Fix Tech Stack Invalid Code
|
||||
|
||||
**Priority:** CRITICAL - Fix first
|
||||
|
||||
**Instructions:**
|
||||
|
||||
```
|
||||
Fix all invalid code based on tech stack issues identified by Agent 1.
|
||||
|
||||
Focus on:
|
||||
1. Fixing TypeScript syntax errors
|
||||
2. Updating deprecated Node.js APIs
|
||||
3. Fixing React 19 compatibility issues
|
||||
4. Correcting Express 5 API usage
|
||||
5. Fixing type errors
|
||||
6. Resolving build-breaking issues
|
||||
|
||||
After fixes, verify:
|
||||
- Code compiles without errors
|
||||
- TypeScript types are correct
|
||||
- No deprecated API usage
|
||||
```
|
||||
|
||||
#### Deepcode Agent 2: Fix Security Vulnerabilities
|
||||
|
||||
**Priority:** HIGH
|
||||
|
||||
**Instructions:**
|
||||
|
||||
```
|
||||
Fix all security vulnerabilities identified by Agent 2.
|
||||
|
||||
Focus on:
|
||||
1. Adding input validation
|
||||
2. Fixing injection vulnerabilities
|
||||
3. Securing authentication/authorization
|
||||
4. Fixing insecure data handling
|
||||
5. Updating vulnerable dependencies
|
||||
6. Securing Electron IPC
|
||||
|
||||
After fixes, verify:
|
||||
- Security vulnerabilities are addressed
|
||||
- No sensitive data exposure
|
||||
- Proper authentication/authorization
|
||||
```
|
||||
|
||||
#### Deepcode Agent 3: Refactor Dirty Code
|
||||
|
||||
**Priority:** MEDIUM
|
||||
|
||||
**Instructions:**
|
||||
|
||||
```
|
||||
Refactor code quality issues identified by Agent 3.
|
||||
|
||||
Focus on:
|
||||
1. Extracting long functions
|
||||
2. Reducing complexity
|
||||
3. Removing duplicate code
|
||||
4. Adding error handling
|
||||
5. Improving React component structure
|
||||
6. Adding missing comments
|
||||
|
||||
After fixes, verify:
|
||||
- Code follows best practices
|
||||
- No code smells remain
|
||||
- Performance optimizations applied
|
||||
```
|
||||
|
||||
#### Deepcode Agent 4: Fix Implementation Errors
|
||||
|
||||
**Priority:** HIGH
|
||||
|
||||
**Instructions:**
|
||||
|
||||
```
|
||||
Fix implementation correctness issues identified by Agent 4.
|
||||
|
||||
Focus on:
|
||||
1. Fixing logic errors
|
||||
2. Adding missing features
|
||||
3. Handling edge cases
|
||||
4. Fixing type errors
|
||||
5. Adding missing tests
|
||||
|
||||
After fixes, verify:
|
||||
- Logic is correct
|
||||
- Edge cases handled
|
||||
- Tests pass
|
||||
```
|
||||
|
||||
#### Deepcode Agent 5: Fix Architectural Issues
|
||||
|
||||
**Priority:** MEDIUM
|
||||
|
||||
**Instructions:**
|
||||
|
||||
```
|
||||
Fix architectural issues identified by Agent 5.
|
||||
|
||||
Focus on:
|
||||
1. Correcting architecture violations
|
||||
2. Applying proper design patterns
|
||||
3. Fixing API design issues
|
||||
4. Improving state management
|
||||
5. Following project patterns
|
||||
|
||||
After fixes, verify:
|
||||
- Architecture is sound
|
||||
- Patterns are correctly applied
|
||||
- Code follows project structure
|
||||
```
|
||||
|
||||
### Phase 5: Verification
|
||||
|
||||
After all fixes are complete:
|
||||
|
||||
1. **Run TypeScript compilation check**
|
||||
|
||||
```bash
|
||||
npm run build:packages
|
||||
```
|
||||
|
||||
2. **Run linting**
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
```
|
||||
|
||||
3. **Run tests** (if applicable)
|
||||
|
||||
```bash
|
||||
npm run test:server
|
||||
npm run test
|
||||
```
|
||||
|
||||
4. **Verify git diff** shows only intended changes
|
||||
|
||||
```bash
|
||||
git diff HEAD
|
||||
```
|
||||
|
||||
5. **Create summary report**:
|
||||
- Issues found by each agent
|
||||
- Issues fixed by each agent
|
||||
- Remaining issues (if any)
|
||||
- Verification results
|
||||
|
||||
## Workflow Summary
|
||||
|
||||
1. ✅ Get git diff
|
||||
2. ✅ Launch 5 deep dive agents (parallel analysis)
|
||||
3. ✅ Consolidate findings and prioritize
|
||||
4. ✅ Launch 5 deepcode agents (sequential fixes, priority order)
|
||||
5. ✅ Verify fixes with build/lint/test
|
||||
6. ✅ Report summary
|
||||
|
||||
## Notes
|
||||
|
||||
- **Tech stack validation is HIGHEST PRIORITY** - invalid code must be fixed first
|
||||
- Each deep dive agent should work independently and provide comprehensive analysis
|
||||
- Deepcode agents should fix issues in priority order
|
||||
- All fixes should maintain existing functionality
|
||||
- If an agent finds no issues in their domain, they should report "No issues found"
|
||||
- If fixes introduce new issues, they should be caught in verification phase
|
||||
45
.claude/commands/thorough.md
Normal file
45
.claude/commands/thorough.md
Normal file
@@ -0,0 +1,45 @@
|
||||
When you think you are done, you are NOT done.
|
||||
|
||||
You must run a mandatory 3-pass verification before concluding:
|
||||
|
||||
## Pass 1: Correctness & Functionality
|
||||
|
||||
- [ ] Verify logic matches requirements and specifications
|
||||
- [ ] Check type safety (TypeScript types are correct and complete)
|
||||
- [ ] Ensure imports are correct and follow project conventions
|
||||
- [ ] Verify all functions/classes work as intended
|
||||
- [ ] Check that return values and side effects are correct
|
||||
- [ ] Run relevant tests if they exist, or verify testability
|
||||
- [ ] Confirm integration with existing code works properly
|
||||
|
||||
## Pass 2: Edge Cases & Safety
|
||||
|
||||
- [ ] Handle null/undefined inputs gracefully
|
||||
- [ ] Validate all user inputs and external data
|
||||
- [ ] Check error handling (try/catch, error boundaries, etc.)
|
||||
- [ ] Verify security considerations (no sensitive data exposure, proper auth checks)
|
||||
- [ ] Test boundary conditions (empty arrays, zero values, max lengths, etc.)
|
||||
- [ ] Ensure resource cleanup (file handles, connections, timers)
|
||||
- [ ] Check for potential race conditions or async issues
|
||||
- [ ] Verify file path security (no directory traversal vulnerabilities)
|
||||
|
||||
## Pass 3: Maintainability & Code Quality
|
||||
|
||||
- [ ] Code follows project style guide and conventions
|
||||
- [ ] Functions/classes are single-purpose and well-named
|
||||
- [ ] Remove dead code, unused imports, and console.logs
|
||||
- [ ] Extract magic numbers/strings into named constants
|
||||
- [ ] Check for code duplication (DRY principle)
|
||||
- [ ] Verify appropriate abstraction levels (not over/under-engineered)
|
||||
- [ ] Add necessary comments for complex logic
|
||||
- [ ] Ensure consistent error messages and logging
|
||||
- [ ] Check that code is readable and self-documenting
|
||||
- [ ] Verify proper separation of concerns
|
||||
|
||||
**For each pass, explicitly report:**
|
||||
|
||||
- What you checked
|
||||
- Any issues found and how they were fixed
|
||||
- Any remaining concerns or trade-offs
|
||||
|
||||
Only after completing all three passes with explicit findings may you conclude the work is done.
|
||||
19
.dockerignore
Normal file
19
.dockerignore
Normal file
@@ -0,0 +1,19 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
**/node_modules/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
**/dist/
|
||||
dist-electron/
|
||||
**/dist-electron/
|
||||
build/
|
||||
**/build/
|
||||
.next/
|
||||
**/.next/
|
||||
.nuxt/
|
||||
**/.nuxt/
|
||||
out/
|
||||
**/out/
|
||||
.cache/
|
||||
**/.cache/
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -81,6 +81,7 @@ blob-report/
|
||||
|
||||
docker-compose.override.yml
|
||||
.claude/docker-compose.override.yml
|
||||
.claude/hans/
|
||||
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
@@ -1 +1,46 @@
|
||||
npx lint-staged
|
||||
#!/usr/bin/env sh
|
||||
|
||||
# Try to load nvm if available (optional - works without it too)
|
||||
if [ -z "$NVM_DIR" ]; then
|
||||
# Check for Herd's nvm first (macOS with Herd)
|
||||
if [ -s "$HOME/Library/Application Support/Herd/config/nvm/nvm.sh" ]; then
|
||||
export NVM_DIR="$HOME/Library/Application Support/Herd/config/nvm"
|
||||
# Then check standard nvm location
|
||||
elif [ -s "$HOME/.nvm/nvm.sh" ]; then
|
||||
export NVM_DIR="$HOME/.nvm"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Source nvm if found (silently skip if not available)
|
||||
[ -n "$NVM_DIR" ] && [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" 2>/dev/null
|
||||
|
||||
# Load node version from .nvmrc if using nvm (silently skip if nvm not available or fails)
|
||||
if [ -f .nvmrc ] && command -v nvm >/dev/null 2>&1; then
|
||||
# Check if Unix nvm was sourced (it's a shell function with NVM_DIR set)
|
||||
if [ -n "$NVM_DIR" ] && type nvm 2>/dev/null | grep -q "function"; then
|
||||
# Unix nvm: reads .nvmrc automatically
|
||||
nvm use >/dev/null 2>&1 || true
|
||||
else
|
||||
# nvm-windows: needs explicit version from .nvmrc
|
||||
NODE_VERSION=$(cat .nvmrc | tr -d '[:space:]')
|
||||
if [ -n "$NODE_VERSION" ]; then
|
||||
nvm use "$NODE_VERSION" >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Ensure common system paths are in PATH (for systems without nvm)
|
||||
# This helps find node/npm installed via Homebrew, system packages, etc.
|
||||
export PATH="$PATH:/usr/local/bin:/opt/homebrew/bin:/usr/bin"
|
||||
|
||||
# Run lint-staged - works with or without nvm
|
||||
# Prefer npx, fallback to npm exec, both work with system-installed Node.js
|
||||
if command -v npx >/dev/null 2>&1; then
|
||||
npx lint-staged
|
||||
elif command -v npm >/dev/null 2>&1; then
|
||||
npm exec -- lint-staged
|
||||
else
|
||||
echo "Error: Neither npx nor npm found in PATH."
|
||||
echo "Please ensure Node.js is installed (via nvm, Homebrew, system package manager, etc.)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -23,6 +23,8 @@ pnpm-lock.yaml
|
||||
# Generated files
|
||||
*.min.js
|
||||
*.min.css
|
||||
routeTree.gen.ts
|
||||
apps/ui/src/routeTree.gen.ts
|
||||
|
||||
# Test artifacts
|
||||
test-results/
|
||||
|
||||
81
Dockerfile
81
Dockerfile
@@ -8,10 +8,12 @@
|
||||
# =============================================================================
|
||||
# BASE STAGE - Common setup for all builds (DRY: defined once, used by all)
|
||||
# =============================================================================
|
||||
FROM node:22-alpine AS base
|
||||
FROM node:22-slim AS base
|
||||
|
||||
# Install build dependencies for native modules (node-pty)
|
||||
RUN apk add --no-cache python3 make g++
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 make g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -51,30 +53,59 @@ RUN npm run build:packages && npm run build --workspace=apps/server
|
||||
# =============================================================================
|
||||
# SERVER PRODUCTION STAGE
|
||||
# =============================================================================
|
||||
FROM node:22-alpine AS server
|
||||
FROM node:22-slim AS server
|
||||
|
||||
# Install git, curl, bash (for terminal), and GitHub CLI (pinned version, multi-arch)
|
||||
RUN apk add --no-cache git curl bash && \
|
||||
GH_VERSION="2.63.2" && \
|
||||
ARCH=$(uname -m) && \
|
||||
case "$ARCH" in \
|
||||
# Install git, curl, bash (for terminal), gosu (for user switching), and GitHub CLI (pinned version, multi-arch)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git curl bash gosu ca-certificates \
|
||||
&& GH_VERSION="2.63.2" \
|
||||
&& ARCH=$(uname -m) \
|
||||
&& case "$ARCH" in \
|
||||
x86_64) GH_ARCH="amd64" ;; \
|
||||
aarch64|arm64) GH_ARCH="arm64" ;; \
|
||||
*) echo "Unsupported architecture: $ARCH" && exit 1 ;; \
|
||||
esac && \
|
||||
curl -L "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_${GH_ARCH}.tar.gz" -o gh.tar.gz && \
|
||||
tar -xzf gh.tar.gz && \
|
||||
mv gh_${GH_VERSION}_linux_${GH_ARCH}/bin/gh /usr/local/bin/gh && \
|
||||
rm -rf gh.tar.gz gh_${GH_VERSION}_linux_${GH_ARCH}
|
||||
esac \
|
||||
&& curl -L "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_${GH_ARCH}.tar.gz" -o gh.tar.gz \
|
||||
&& tar -xzf gh.tar.gz \
|
||||
&& mv gh_${GH_VERSION}_linux_${GH_ARCH}/bin/gh /usr/local/bin/gh \
|
||||
&& rm -rf gh.tar.gz gh_${GH_VERSION}_linux_${GH_ARCH} \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Claude CLI globally
|
||||
# Install Claude CLI globally (available to all users via npm global bin)
|
||||
RUN npm install -g @anthropic-ai/claude-code
|
||||
|
||||
WORKDIR /app
|
||||
# Create non-root user with home directory BEFORE installing Cursor CLI
|
||||
RUN groupadd -g 1001 automaker && \
|
||||
useradd -u 1001 -g automaker -m -d /home/automaker -s /bin/bash automaker && \
|
||||
mkdir -p /home/automaker/.local/bin && \
|
||||
mkdir -p /home/automaker/.cursor && \
|
||||
chown -R automaker:automaker /home/automaker && \
|
||||
chmod 700 /home/automaker/.cursor
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1001 -S automaker && \
|
||||
adduser -S automaker -u 1001
|
||||
# Install Cursor CLI as the automaker user
|
||||
# Set HOME explicitly and install to /home/automaker/.local/bin/
|
||||
USER automaker
|
||||
ENV HOME=/home/automaker
|
||||
RUN curl https://cursor.com/install -fsS | bash && \
|
||||
echo "=== Checking Cursor CLI installation ===" && \
|
||||
ls -la /home/automaker/.local/bin/ && \
|
||||
echo "=== PATH is: $PATH ===" && \
|
||||
(which cursor-agent && cursor-agent --version) || echo "cursor-agent installed (may need auth setup)"
|
||||
USER root
|
||||
|
||||
# Add PATH to profile so it's available in all interactive shells (for login shells)
|
||||
RUN mkdir -p /etc/profile.d && \
|
||||
echo 'export PATH="/home/automaker/.local/bin:$PATH"' > /etc/profile.d/cursor-cli.sh && \
|
||||
chmod +x /etc/profile.d/cursor-cli.sh
|
||||
|
||||
# Add to automaker's .bashrc for bash interactive shells
|
||||
RUN echo 'export PATH="/home/automaker/.local/bin:$PATH"' >> /home/automaker/.bashrc && \
|
||||
chown automaker:automaker /home/automaker/.bashrc
|
||||
|
||||
# Also add to root's .bashrc since docker exec defaults to root
|
||||
RUN echo 'export PATH="/home/automaker/.local/bin:$PATH"' >> /root/.bashrc
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy root package.json (needed for workspace resolution)
|
||||
COPY --from=server-builder /app/package*.json ./
|
||||
@@ -98,12 +129,19 @@ RUN git config --system --add safe.directory '*' && \
|
||||
# Use gh as credential helper (works with GH_TOKEN env var)
|
||||
git config --system credential.helper '!gh auth git-credential'
|
||||
|
||||
# Switch to non-root user
|
||||
USER automaker
|
||||
# Copy entrypoint script for fixing permissions on mounted volumes
|
||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
# Note: We stay as root here so entrypoint can fix permissions
|
||||
# The entrypoint script will switch to automaker user before running the command
|
||||
|
||||
# Environment variables
|
||||
ENV PORT=3008
|
||||
ENV DATA_DIR=/data
|
||||
ENV HOME=/home/automaker
|
||||
# Add user's local bin to PATH for cursor-agent
|
||||
ENV PATH="/home/automaker/.local/bin:${PATH}"
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3008
|
||||
@@ -112,6 +150,9 @@ EXPOSE 3008
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:3008/api/health || exit 1
|
||||
|
||||
# Use entrypoint to fix permissions before starting
|
||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||
|
||||
# Start server
|
||||
CMD ["node", "apps/server/dist/index.js"]
|
||||
|
||||
|
||||
36
README.md
36
README.md
@@ -120,29 +120,37 @@ npm install
|
||||
# 3. Build shared packages (Now can be skipped npm install / run dev does it automaticly)
|
||||
npm run build:packages
|
||||
|
||||
# 4. Set up authentication (skip if using Claude Code CLI)
|
||||
# If using Claude Code CLI: credentials are detected automatically
|
||||
# If using API key directly, choose one method:
|
||||
|
||||
# Option A: Environment variable
|
||||
export ANTHROPIC_API_KEY="sk-ant-..."
|
||||
|
||||
# Option B: Create .env file in project root
|
||||
echo "ANTHROPIC_API_KEY=sk-ant-..." > .env
|
||||
|
||||
# 5. Start Automaker (interactive launcher)
|
||||
npm run dev
|
||||
# 4. Start Automaker (production mode)
|
||||
npm run start
|
||||
# Choose between:
|
||||
# 1. Web Application (browser at localhost:3007)
|
||||
# 2. Desktop Application (Electron - recommended)
|
||||
```
|
||||
|
||||
**Note:** The `npm run dev` command will:
|
||||
**Note:** The `npm run start` command will:
|
||||
|
||||
- Check for dependencies and install if needed
|
||||
- Install Playwright browsers for E2E tests
|
||||
- Build the application if needed
|
||||
- Kill any processes on ports 3007/3008
|
||||
- Present an interactive menu to choose your run mode
|
||||
- Run in production mode (no hot reload)
|
||||
|
||||
**Authentication Setup:** On first run, Automaker will automatically show a setup wizard where you can configure authentication. You can choose to:
|
||||
|
||||
- Use **Claude Code CLI** (recommended) - Automaker will detect your CLI credentials automatically
|
||||
- Enter an **API key** directly in the wizard
|
||||
|
||||
If you prefer to set up authentication before running (e.g., for headless deployments or CI/CD), you can set it manually:
|
||||
|
||||
```bash
|
||||
# Option A: Environment variable
|
||||
export ANTHROPIC_API_KEY="sk-ant-..."
|
||||
|
||||
# Option B: Create .env file in project root
|
||||
echo "ANTHROPIC_API_KEY=sk-ant-..." > .env
|
||||
```
|
||||
|
||||
**For Development:** If you want to develop on Automaker with Vite live reload and hot module replacement, use `npm run dev` instead. This will start the development server with fast refresh and instant updates as you make changes.
|
||||
|
||||
## How to Run
|
||||
|
||||
|
||||
17
TODO.md
Normal file
17
TODO.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Bugs
|
||||
|
||||
- Setting the default model does not seem like it works.
|
||||
|
||||
# UX
|
||||
|
||||
- Consolidate all models to a single place in the settings instead of having AI profiles and all this other stuff
|
||||
- Simplify the create feature modal. It should just be one page. I don't need nessa tabs and all these nested buttons. It's too complex.
|
||||
- added to do's list checkbox directly into the card so as it's going through if there's any to do items we can see those update live
|
||||
- When the feature is done, I want to see a summary of the LLM. That's the first thing I should see when I double click the card.
|
||||
- I went away to mass edit all my features. For example, when I created a new project, it added auto testing on every single feature card. Now I have to manually go through one by one and change those. Have a way to mass edit those, the configuration of all them.
|
||||
- Double check and debug if there's memory leaks. It seems like the memory of automaker grows like 3 gigabytes. It's 5gb right now and I'm running three different cursor cli features implementing at the same time.
|
||||
- Typing in the text area of the plan mode was super laggy.
|
||||
- When I have a bunch of features running at the same time, it seems like I cannot edit the features in the backlog. Like they don't persist their file changes and I think this is because of the secure FS file has an internal queue to prevent hitting that file open write limit. We may have to reconsider refactoring away from file system and do Postgres or SQLite or something.
|
||||
- modals are not scrollable if height of the screen is small enough
|
||||
- and the Agent Runner add an archival button for the new sessions.
|
||||
- investigate a potential issue with the feature cards not refreshing. I see a lock icon on the feature card But it doesn't go away until I open the card and edit it and I turn the testing mode off. I think there's like a refresh sync issue.
|
||||
@@ -48,3 +48,15 @@ TERMINAL_ENABLED=true
|
||||
TERMINAL_PASSWORD=
|
||||
|
||||
ENABLE_REQUEST_LOGGING=false
|
||||
|
||||
# ============================================
|
||||
# OPTIONAL - Debugging
|
||||
# ============================================
|
||||
|
||||
# Enable raw output logging for agent streams (default: false)
|
||||
# When enabled, saves unprocessed stream events to raw-output.jsonl
|
||||
# in each feature's directory (.automaker/features/{id}/raw-output.jsonl)
|
||||
# Useful for debugging provider streaming issues, improving log parsing,
|
||||
# or analyzing how different providers (Claude, Cursor) stream responses
|
||||
# Note: This adds disk I/O overhead, only enable when debugging
|
||||
AUTOMAKER_DEBUG_RAW_OUTPUT=false
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@automaker/server",
|
||||
"version": "0.7.2",
|
||||
"version": "0.8.0",
|
||||
"description": "Backend server for Automaker - provides API for both web and Electron modes",
|
||||
"author": "AutoMaker Team",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
|
||||
@@ -17,6 +17,9 @@ import dotenv from 'dotenv';
|
||||
|
||||
import { createEventEmitter, type EventEmitter } from './lib/events.js';
|
||||
import { initAllowedPaths } from '@automaker/platform';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('Server');
|
||||
import { authMiddleware, validateWsConnectionToken, checkRawAuthentication } from './lib/auth.js';
|
||||
import { requireJsonContentType } from './middleware/require-json-content-type.js';
|
||||
import { createAuthRoutes } from './routes/auth/index.js';
|
||||
@@ -58,6 +61,8 @@ import { createMCPRoutes } from './routes/mcp/index.js';
|
||||
import { MCPTestService } from './services/mcp-test-service.js';
|
||||
import { createPipelineRoutes } from './routes/pipeline/index.js';
|
||||
import { pipelineService } from './services/pipeline-service.js';
|
||||
import { createIdeationRoutes } from './routes/ideation/index.js';
|
||||
import { IdeationService } from './services/ideation-service.js';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
@@ -70,7 +75,7 @@ const ENABLE_REQUEST_LOGGING = process.env.ENABLE_REQUEST_LOGGING !== 'false'; /
|
||||
const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
|
||||
|
||||
if (!hasAnthropicKey) {
|
||||
console.warn(`
|
||||
logger.warn(`
|
||||
╔═══════════════════════════════════════════════════════════════════════╗
|
||||
║ ⚠️ WARNING: No Claude authentication configured ║
|
||||
║ ║
|
||||
@@ -83,7 +88,7 @@ if (!hasAnthropicKey) {
|
||||
╚═══════════════════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
} else {
|
||||
console.log('[Server] ✓ ANTHROPIC_API_KEY detected (API key auth)');
|
||||
logger.info('✓ ANTHROPIC_API_KEY detected (API key auth)');
|
||||
}
|
||||
|
||||
// Initialize security
|
||||
@@ -162,11 +167,12 @@ const featureLoader = new FeatureLoader();
|
||||
const autoModeService = new AutoModeService(events, settingsService);
|
||||
const claudeUsageService = new ClaudeUsageService();
|
||||
const mcpTestService = new MCPTestService(settingsService);
|
||||
const ideationService = new IdeationService(events, settingsService, featureLoader);
|
||||
|
||||
// Initialize services
|
||||
(async () => {
|
||||
await agentService.initialize();
|
||||
console.log('[Server] Agent service initialized');
|
||||
logger.info('Agent service initialized');
|
||||
})();
|
||||
|
||||
// Run stale validation cleanup every hour to prevent memory leaks from crashed validations
|
||||
@@ -174,7 +180,7 @@ const VALIDATION_CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
||||
setInterval(() => {
|
||||
const cleaned = cleanupStaleValidations();
|
||||
if (cleaned > 0) {
|
||||
console.log(`[Server] Cleaned up ${cleaned} stale validation entries`);
|
||||
logger.info(`Cleaned up ${cleaned} stale validation entries`);
|
||||
}
|
||||
}, VALIDATION_CLEANUP_INTERVAL_MS);
|
||||
|
||||
@@ -215,6 +221,7 @@ app.use('/api/context', createContextRoutes(settingsService));
|
||||
app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService));
|
||||
app.use('/api/mcp', createMCPRoutes(mcpTestService));
|
||||
app.use('/api/pipeline', createPipelineRoutes(pipelineService));
|
||||
app.use('/api/ideation', createIdeationRoutes(events, ideationService, featureLoader));
|
||||
|
||||
// Create HTTP server
|
||||
const server = createServer(app);
|
||||
@@ -267,7 +274,7 @@ server.on('upgrade', (request, socket, head) => {
|
||||
|
||||
// Authenticate all WebSocket connections
|
||||
if (!authenticateWebSocket(request)) {
|
||||
console.log('[WebSocket] Authentication failed, rejecting connection');
|
||||
logger.info('Authentication failed, rejecting connection');
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
@@ -288,11 +295,11 @@ server.on('upgrade', (request, socket, head) => {
|
||||
|
||||
// Events WebSocket connection handler
|
||||
wss.on('connection', (ws: WebSocket) => {
|
||||
console.log('[WebSocket] Client connected, ready state:', ws.readyState);
|
||||
logger.info('Client connected, ready state:', ws.readyState);
|
||||
|
||||
// Subscribe to all events and forward to this client
|
||||
const unsubscribe = events.subscribe((type, payload) => {
|
||||
console.log('[WebSocket] Event received:', {
|
||||
logger.info('Event received:', {
|
||||
type,
|
||||
hasPayload: !!payload,
|
||||
payloadKeys: payload ? Object.keys(payload) : [],
|
||||
@@ -302,27 +309,24 @@ wss.on('connection', (ws: WebSocket) => {
|
||||
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
const message = JSON.stringify({ type, payload });
|
||||
console.log('[WebSocket] Sending event to client:', {
|
||||
logger.info('Sending event to client:', {
|
||||
type,
|
||||
messageLength: message.length,
|
||||
sessionId: (payload as any)?.sessionId,
|
||||
});
|
||||
ws.send(message);
|
||||
} else {
|
||||
console.log(
|
||||
'[WebSocket] WARNING: Cannot send event, WebSocket not open. ReadyState:',
|
||||
ws.readyState
|
||||
);
|
||||
logger.info('WARNING: Cannot send event, WebSocket not open. ReadyState:', ws.readyState);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('[WebSocket] Client disconnected');
|
||||
logger.info('Client disconnected');
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error('[WebSocket] ERROR:', error);
|
||||
logger.error('ERROR:', error);
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
@@ -349,24 +353,24 @@ terminalWss.on('connection', (ws: WebSocket, req: import('http').IncomingMessage
|
||||
const sessionId = url.searchParams.get('sessionId');
|
||||
const token = url.searchParams.get('token');
|
||||
|
||||
console.log(`[Terminal WS] Connection attempt for session: ${sessionId}`);
|
||||
logger.info(`Connection attempt for session: ${sessionId}`);
|
||||
|
||||
// Check if terminal is enabled
|
||||
if (!isTerminalEnabled()) {
|
||||
console.log('[Terminal WS] Terminal is disabled');
|
||||
logger.info('Terminal is disabled');
|
||||
ws.close(4003, 'Terminal access is disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate token if password is required
|
||||
if (isTerminalPasswordRequired() && !validateTerminalToken(token || undefined)) {
|
||||
console.log('[Terminal WS] Invalid or missing token');
|
||||
logger.info('Invalid or missing token');
|
||||
ws.close(4001, 'Authentication required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
console.log('[Terminal WS] No session ID provided');
|
||||
logger.info('No session ID provided');
|
||||
ws.close(4002, 'Session ID required');
|
||||
return;
|
||||
}
|
||||
@@ -374,12 +378,12 @@ terminalWss.on('connection', (ws: WebSocket, req: import('http').IncomingMessage
|
||||
// Check if session exists
|
||||
const session = terminalService.getSession(sessionId);
|
||||
if (!session) {
|
||||
console.log(`[Terminal WS] Session ${sessionId} not found`);
|
||||
logger.info(`Session ${sessionId} not found`);
|
||||
ws.close(4004, 'Session not found');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[Terminal WS] Client connected to session ${sessionId}`);
|
||||
logger.info(`Client connected to session ${sessionId}`);
|
||||
|
||||
// Track this connection
|
||||
if (!terminalConnections.has(sessionId)) {
|
||||
@@ -495,15 +499,15 @@ terminalWss.on('connection', (ws: WebSocket, req: import('http').IncomingMessage
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn(`[Terminal WS] Unknown message type: ${msg.type}`);
|
||||
logger.warn(`Unknown message type: ${msg.type}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Terminal WS] Error processing message:', error);
|
||||
logger.error('Error processing message:', error);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log(`[Terminal WS] Client disconnected from session ${sessionId}`);
|
||||
logger.info(`Client disconnected from session ${sessionId}`);
|
||||
unsubscribeData();
|
||||
unsubscribeExit();
|
||||
|
||||
@@ -522,7 +526,7 @@ terminalWss.on('connection', (ws: WebSocket, req: import('http').IncomingMessage
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error(`[Terminal WS] Error on session ${sessionId}:`, error);
|
||||
logger.error(`Error on session ${sessionId}:`, error);
|
||||
unsubscribeData();
|
||||
unsubscribeExit();
|
||||
});
|
||||
@@ -537,7 +541,7 @@ const startServer = (port: number) => {
|
||||
: 'enabled'
|
||||
: 'disabled';
|
||||
const portStr = port.toString().padEnd(4);
|
||||
console.log(`
|
||||
logger.info(`
|
||||
╔═══════════════════════════════════════════════════════╗
|
||||
║ Automaker Backend Server ║
|
||||
╠═══════════════════════════════════════════════════════╣
|
||||
@@ -552,7 +556,7 @@ const startServer = (port: number) => {
|
||||
|
||||
server.on('error', (error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'EADDRINUSE') {
|
||||
console.error(`
|
||||
logger.error(`
|
||||
╔═══════════════════════════════════════════════════════╗
|
||||
║ ❌ ERROR: Port ${port} is already in use ║
|
||||
╠═══════════════════════════════════════════════════════╣
|
||||
@@ -572,7 +576,7 @@ const startServer = (port: number) => {
|
||||
`);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.error('[Server] Error starting server:', error);
|
||||
logger.error('Error starting server:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -582,19 +586,19 @@ startServer(PORT);
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('SIGTERM received, shutting down...');
|
||||
logger.info('SIGTERM received, shutting down...');
|
||||
terminalService.cleanup();
|
||||
server.close(() => {
|
||||
console.log('Server closed');
|
||||
logger.info('Server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('SIGINT received, shutting down...');
|
||||
logger.info('SIGINT received, shutting down...');
|
||||
terminalService.cleanup();
|
||||
server.close(() => {
|
||||
console.log('Server closed');
|
||||
logger.info('Server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,9 @@ import type { Request, Response, NextFunction } from 'express';
|
||||
import crypto from 'crypto';
|
||||
import path from 'path';
|
||||
import * as secureFs from './secure-fs.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('Auth');
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || './data';
|
||||
const API_KEY_FILE = path.join(DATA_DIR, '.api-key');
|
||||
@@ -61,11 +64,11 @@ function loadSessions(): void {
|
||||
}
|
||||
|
||||
if (loadedCount > 0 || expiredCount > 0) {
|
||||
console.log(`[Auth] Loaded ${loadedCount} sessions (${expiredCount} expired)`);
|
||||
logger.info(`Loaded ${loadedCount} sessions (${expiredCount} expired)`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Auth] Error loading sessions:', error);
|
||||
logger.warn('Error loading sessions:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +84,7 @@ async function saveSessions(): Promise<void> {
|
||||
mode: 0o600,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Auth] Failed to save sessions:', error);
|
||||
logger.error('Failed to save sessions:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +98,7 @@ loadSessions();
|
||||
function ensureApiKey(): string {
|
||||
// First check environment variable (Electron passes it this way)
|
||||
if (process.env.AUTOMAKER_API_KEY) {
|
||||
console.log('[Auth] Using API key from environment variable');
|
||||
logger.info('Using API key from environment variable');
|
||||
return process.env.AUTOMAKER_API_KEY;
|
||||
}
|
||||
|
||||
@@ -104,12 +107,12 @@ function ensureApiKey(): string {
|
||||
if (secureFs.existsSync(API_KEY_FILE)) {
|
||||
const key = (secureFs.readFileSync(API_KEY_FILE, 'utf-8') as string).trim();
|
||||
if (key) {
|
||||
console.log('[Auth] Loaded API key from file');
|
||||
logger.info('Loaded API key from file');
|
||||
return key;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Auth] Error reading API key file:', error);
|
||||
logger.warn('Error reading API key file:', error);
|
||||
}
|
||||
|
||||
// Generate new key
|
||||
@@ -117,9 +120,9 @@ function ensureApiKey(): string {
|
||||
try {
|
||||
secureFs.mkdirSync(path.dirname(API_KEY_FILE), { recursive: true });
|
||||
secureFs.writeFileSync(API_KEY_FILE, newKey, { encoding: 'utf-8', mode: 0o600 });
|
||||
console.log('[Auth] Generated new API key');
|
||||
logger.info('Generated new API key');
|
||||
} catch (error) {
|
||||
console.error('[Auth] Failed to save API key:', error);
|
||||
logger.error('Failed to save API key:', error);
|
||||
}
|
||||
return newKey;
|
||||
}
|
||||
@@ -129,7 +132,7 @@ const API_KEY = ensureApiKey();
|
||||
|
||||
// Print API key to console for web mode users (unless suppressed for production logging)
|
||||
if (process.env.AUTOMAKER_HIDE_API_KEY !== 'true') {
|
||||
console.log(`
|
||||
logger.info(`
|
||||
╔═══════════════════════════════════════════════════════════════════════╗
|
||||
║ 🔐 API Key for Web Mode Authentication ║
|
||||
╠═══════════════════════════════════════════════════════════════════════╣
|
||||
@@ -142,7 +145,7 @@ if (process.env.AUTOMAKER_HIDE_API_KEY !== 'true') {
|
||||
╚═══════════════════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
} else {
|
||||
console.log('[Auth] API key banner hidden (AUTOMAKER_HIDE_API_KEY=true)');
|
||||
logger.info('API key banner hidden (AUTOMAKER_HIDE_API_KEY=true)');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -177,7 +180,7 @@ export function validateSession(token: string): boolean {
|
||||
if (Date.now() > session.expiresAt) {
|
||||
validSessions.delete(token);
|
||||
// Fire-and-forget: persist removal asynchronously
|
||||
saveSessions().catch((err) => console.error('[Auth] Error saving sessions:', err));
|
||||
saveSessions().catch((err) => logger.error('Error saving sessions:', err));
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
*/
|
||||
|
||||
import type { EventType, EventCallback } from '@automaker/types';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('Events');
|
||||
|
||||
// Re-export event types from shared package
|
||||
export type { EventType, EventCallback };
|
||||
@@ -21,7 +24,7 @@ export function createEventEmitter(): EventEmitter {
|
||||
try {
|
||||
callback(type, payload);
|
||||
} catch (error) {
|
||||
console.error('Error in event subscriber:', error);
|
||||
logger.error('Error in event subscriber:', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
211
apps/server/src/lib/json-extractor.ts
Normal file
211
apps/server/src/lib/json-extractor.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* JSON Extraction Utilities
|
||||
*
|
||||
* Robust JSON extraction from AI responses that may contain markdown,
|
||||
* code blocks, or other text mixed with JSON content.
|
||||
*
|
||||
* Used by various routes that parse structured output from Cursor or
|
||||
* Claude responses when structured output is not available.
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('JsonExtractor');
|
||||
|
||||
/**
|
||||
* Logger interface for optional custom logging
|
||||
*/
|
||||
export interface JsonExtractorLogger {
|
||||
debug: (message: string, ...args: unknown[]) => void;
|
||||
warn?: (message: string, ...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for JSON extraction
|
||||
*/
|
||||
export interface ExtractJsonOptions {
|
||||
/** Custom logger (defaults to internal logger) */
|
||||
logger?: JsonExtractorLogger;
|
||||
/** Required key that must be present in the extracted JSON */
|
||||
requiredKey?: string;
|
||||
/** Whether the required key's value must be an array */
|
||||
requireArray?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract JSON from response text using multiple strategies.
|
||||
*
|
||||
* Strategies tried in order:
|
||||
* 1. JSON in ```json code block
|
||||
* 2. JSON in ``` code block (no language)
|
||||
* 3. Find JSON object by matching braces (starting with requiredKey if specified)
|
||||
* 4. Find any JSON object by matching braces
|
||||
* 5. Parse entire response as JSON
|
||||
*
|
||||
* @param responseText - The raw response text that may contain JSON
|
||||
* @param options - Optional extraction options
|
||||
* @returns Parsed JSON object or null if extraction fails
|
||||
*/
|
||||
export function extractJson<T = Record<string, unknown>>(
|
||||
responseText: string,
|
||||
options: ExtractJsonOptions = {}
|
||||
): T | null {
|
||||
const log = options.logger || logger;
|
||||
const requiredKey = options.requiredKey;
|
||||
const requireArray = options.requireArray ?? false;
|
||||
|
||||
/**
|
||||
* Validate that the result has the required key/structure
|
||||
*/
|
||||
const validateResult = (result: unknown): result is T => {
|
||||
if (!result || typeof result !== 'object') return false;
|
||||
if (requiredKey) {
|
||||
const obj = result as Record<string, unknown>;
|
||||
if (!(requiredKey in obj)) return false;
|
||||
if (requireArray && !Array.isArray(obj[requiredKey])) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Find matching closing brace by counting brackets
|
||||
*/
|
||||
const findMatchingBrace = (text: string, startIdx: number): number => {
|
||||
let depth = 0;
|
||||
for (let i = startIdx; i < text.length; i++) {
|
||||
if (text[i] === '{') depth++;
|
||||
if (text[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
return i + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
const strategies = [
|
||||
// Strategy 1: JSON in ```json code block
|
||||
() => {
|
||||
const match = responseText.match(/```json\s*([\s\S]*?)```/);
|
||||
if (match) {
|
||||
log.debug('Extracting JSON from ```json code block');
|
||||
return JSON.parse(match[1].trim());
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
// Strategy 2: JSON in ``` code block (no language specified)
|
||||
() => {
|
||||
const match = responseText.match(/```\s*([\s\S]*?)```/);
|
||||
if (match) {
|
||||
const content = match[1].trim();
|
||||
// Only try if it looks like JSON (starts with { or [)
|
||||
if (content.startsWith('{') || content.startsWith('[')) {
|
||||
log.debug('Extracting JSON from ``` code block');
|
||||
return JSON.parse(content);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
// Strategy 3: Find JSON object containing the required key (if specified)
|
||||
() => {
|
||||
if (!requiredKey) return null;
|
||||
|
||||
const searchPattern = `{"${requiredKey}"`;
|
||||
const startIdx = responseText.indexOf(searchPattern);
|
||||
if (startIdx === -1) return null;
|
||||
|
||||
const endIdx = findMatchingBrace(responseText, startIdx);
|
||||
if (endIdx > startIdx) {
|
||||
log.debug(`Extracting JSON with required key "${requiredKey}"`);
|
||||
return JSON.parse(responseText.slice(startIdx, endIdx));
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
// Strategy 4: Find any JSON object by matching braces
|
||||
() => {
|
||||
const startIdx = responseText.indexOf('{');
|
||||
if (startIdx === -1) return null;
|
||||
|
||||
const endIdx = findMatchingBrace(responseText, startIdx);
|
||||
if (endIdx > startIdx) {
|
||||
log.debug('Extracting JSON by brace matching');
|
||||
return JSON.parse(responseText.slice(startIdx, endIdx));
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
// Strategy 5: Find JSON using first { to last } (may be less accurate)
|
||||
() => {
|
||||
const firstBrace = responseText.indexOf('{');
|
||||
const lastBrace = responseText.lastIndexOf('}');
|
||||
if (firstBrace !== -1 && lastBrace > firstBrace) {
|
||||
log.debug('Extracting JSON from first { to last }');
|
||||
return JSON.parse(responseText.slice(firstBrace, lastBrace + 1));
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
// Strategy 6: Try parsing the entire response as JSON
|
||||
() => {
|
||||
const trimmed = responseText.trim();
|
||||
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
||||
log.debug('Parsing entire response as JSON');
|
||||
return JSON.parse(trimmed);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
];
|
||||
|
||||
for (const strategy of strategies) {
|
||||
try {
|
||||
const result = strategy();
|
||||
if (validateResult(result)) {
|
||||
log.debug('Successfully extracted JSON');
|
||||
return result as T;
|
||||
}
|
||||
} catch {
|
||||
// Strategy failed, try next
|
||||
}
|
||||
}
|
||||
|
||||
log.debug('Failed to extract JSON from response');
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract JSON with a specific required key.
|
||||
* Convenience wrapper around extractJson.
|
||||
*
|
||||
* @param responseText - The raw response text
|
||||
* @param requiredKey - Key that must be present in the extracted JSON
|
||||
* @param options - Additional options
|
||||
* @returns Parsed JSON object or null
|
||||
*/
|
||||
export function extractJsonWithKey<T = Record<string, unknown>>(
|
||||
responseText: string,
|
||||
requiredKey: string,
|
||||
options: Omit<ExtractJsonOptions, 'requiredKey'> = {}
|
||||
): T | null {
|
||||
return extractJson<T>(responseText, { ...options, requiredKey });
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract JSON that has a required array property.
|
||||
* Useful for extracting responses like { "suggestions": [...] }
|
||||
*
|
||||
* @param responseText - The raw response text
|
||||
* @param arrayKey - Key that must contain an array
|
||||
* @param options - Additional options
|
||||
* @returns Parsed JSON object or null
|
||||
*/
|
||||
export function extractJsonWithArray<T = Record<string, unknown>>(
|
||||
responseText: string,
|
||||
arrayKey: string,
|
||||
options: Omit<ExtractJsonOptions, 'requiredKey' | 'requireArray'> = {}
|
||||
): T | null {
|
||||
return extractJson<T>(responseText, { ...options, requiredKey: arrayKey, requireArray: true });
|
||||
}
|
||||
@@ -16,9 +16,19 @@
|
||||
*/
|
||||
|
||||
import type { Options } from '@anthropic-ai/claude-agent-sdk';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { resolveModelString } from '@automaker/model-resolver';
|
||||
import { DEFAULT_MODELS, CLAUDE_MODEL_MAP, type McpServerConfig } from '@automaker/types';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('SdkOptions');
|
||||
import {
|
||||
DEFAULT_MODELS,
|
||||
CLAUDE_MODEL_MAP,
|
||||
type McpServerConfig,
|
||||
type ThinkingLevel,
|
||||
getThinkingTokenBudget,
|
||||
} from '@automaker/types';
|
||||
import { isPathAllowed, PathNotAllowedError, getAllowedRootDirectory } from '@automaker/platform';
|
||||
|
||||
/**
|
||||
@@ -47,6 +57,139 @@ export function validateWorkingDirectory(cwd: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Known cloud storage path patterns where sandbox mode is incompatible.
|
||||
*
|
||||
* The Claude CLI sandbox feature uses filesystem isolation that conflicts with
|
||||
* cloud storage providers' virtual filesystem implementations. This causes the
|
||||
* Claude process to exit with code 1 when sandbox is enabled for these paths.
|
||||
*
|
||||
* Affected providers (macOS paths):
|
||||
* - Dropbox: ~/Library/CloudStorage/Dropbox-*
|
||||
* - Google Drive: ~/Library/CloudStorage/GoogleDrive-*
|
||||
* - OneDrive: ~/Library/CloudStorage/OneDrive-*
|
||||
* - iCloud Drive: ~/Library/Mobile Documents/
|
||||
* - Box: ~/Library/CloudStorage/Box-*
|
||||
*
|
||||
* Note: This is a known limitation when using cloud storage paths.
|
||||
*/
|
||||
|
||||
/**
|
||||
* macOS-specific cloud storage patterns that appear under ~/Library/
|
||||
* These are specific enough to use with includes() safely.
|
||||
*/
|
||||
const MACOS_CLOUD_STORAGE_PATTERNS = [
|
||||
'/Library/CloudStorage/', // Dropbox, Google Drive, OneDrive, Box on macOS
|
||||
'/Library/Mobile Documents/', // iCloud Drive on macOS
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Generic cloud storage folder names that need to be anchored to the home directory
|
||||
* to avoid false positives (e.g., /home/user/my-project-about-dropbox/).
|
||||
*/
|
||||
const HOME_ANCHORED_CLOUD_FOLDERS = [
|
||||
'Google Drive', // Google Drive on some systems
|
||||
'Dropbox', // Dropbox on Linux/alternative installs
|
||||
'OneDrive', // OneDrive on Linux/alternative installs
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Check if a path is within a cloud storage location.
|
||||
*
|
||||
* Cloud storage providers use virtual filesystem implementations that are
|
||||
* incompatible with the Claude CLI sandbox feature, causing process crashes.
|
||||
*
|
||||
* Uses two detection strategies:
|
||||
* 1. macOS-specific patterns (under ~/Library/) - checked via includes()
|
||||
* 2. Generic folder names - anchored to home directory to avoid false positives
|
||||
*
|
||||
* @param cwd - The working directory path to check
|
||||
* @returns true if the path is in a cloud storage location
|
||||
*/
|
||||
export function isCloudStoragePath(cwd: string): boolean {
|
||||
const resolvedPath = path.resolve(cwd);
|
||||
// Normalize to forward slashes for consistent pattern matching across platforms
|
||||
let normalizedPath = resolvedPath.split(path.sep).join('/');
|
||||
// Remove Windows drive letter if present (e.g., "C:/Users" -> "/Users")
|
||||
// This ensures Unix paths in tests work the same on Windows
|
||||
normalizedPath = normalizedPath.replace(/^[A-Za-z]:/, '');
|
||||
|
||||
// Check macOS-specific patterns (these are specific enough to use includes)
|
||||
if (MACOS_CLOUD_STORAGE_PATTERNS.some((pattern) => normalizedPath.includes(pattern))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check home-anchored patterns to avoid false positives
|
||||
// e.g., /home/user/my-project-about-dropbox/ should NOT match
|
||||
const home = os.homedir();
|
||||
for (const folder of HOME_ANCHORED_CLOUD_FOLDERS) {
|
||||
const cloudPath = path.join(home, folder);
|
||||
let normalizedCloudPath = cloudPath.split(path.sep).join('/');
|
||||
// Remove Windows drive letter if present
|
||||
normalizedCloudPath = normalizedCloudPath.replace(/^[A-Za-z]:/, '');
|
||||
// Check if resolved path starts with the cloud storage path followed by a separator
|
||||
// This ensures we match ~/Dropbox/project but not ~/Dropbox-archive or ~/my-dropbox-tool
|
||||
if (
|
||||
normalizedPath === normalizedCloudPath ||
|
||||
normalizedPath.startsWith(normalizedCloudPath + '/')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of sandbox compatibility check
|
||||
*/
|
||||
export interface SandboxCheckResult {
|
||||
/** Whether sandbox should be enabled */
|
||||
enabled: boolean;
|
||||
/** If disabled, the reason why */
|
||||
disabledReason?: 'cloud_storage' | 'user_setting';
|
||||
/** Human-readable message for logging/UI */
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if sandbox mode should be enabled for a given configuration.
|
||||
*
|
||||
* Sandbox mode is automatically disabled for cloud storage paths because the
|
||||
* Claude CLI sandbox feature is incompatible with virtual filesystem
|
||||
* implementations used by cloud storage providers (Dropbox, Google Drive, etc.).
|
||||
*
|
||||
* @param cwd - The working directory
|
||||
* @param enableSandboxMode - User's sandbox mode setting
|
||||
* @returns SandboxCheckResult with enabled status and reason if disabled
|
||||
*/
|
||||
export function checkSandboxCompatibility(
|
||||
cwd: string,
|
||||
enableSandboxMode?: boolean
|
||||
): SandboxCheckResult {
|
||||
// User has explicitly disabled sandbox mode
|
||||
if (enableSandboxMode === false) {
|
||||
return {
|
||||
enabled: false,
|
||||
disabledReason: 'user_setting',
|
||||
};
|
||||
}
|
||||
|
||||
// Check for cloud storage incompatibility (applies when enabled or undefined)
|
||||
if (isCloudStoragePath(cwd)) {
|
||||
return {
|
||||
enabled: false,
|
||||
disabledReason: 'cloud_storage',
|
||||
message: `Sandbox mode auto-disabled: Project is in a cloud storage location (${cwd}). The Claude CLI sandbox feature is incompatible with cloud storage filesystems. To use sandbox mode, move your project to a local directory.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Sandbox is compatible and enabled (true or undefined defaults to enabled)
|
||||
return {
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool presets for different use cases
|
||||
*/
|
||||
@@ -183,6 +326,21 @@ function buildMcpOptions(config: CreateSdkOptionsConfig): McpPermissionOptions {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build thinking options for SDK configuration.
|
||||
* Converts ThinkingLevel to maxThinkingTokens for the Claude SDK.
|
||||
*
|
||||
* @param thinkingLevel - The thinking level to convert
|
||||
* @returns Object with maxThinkingTokens if thinking is enabled
|
||||
*/
|
||||
function buildThinkingOptions(thinkingLevel?: ThinkingLevel): Partial<Options> {
|
||||
const maxThinkingTokens = getThinkingTokenBudget(thinkingLevel);
|
||||
logger.debug(
|
||||
`buildThinkingOptions: thinkingLevel="${thinkingLevel}" -> maxThinkingTokens=${maxThinkingTokens}`
|
||||
);
|
||||
return maxThinkingTokens ? { maxThinkingTokens } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build system prompt configuration based on autoLoadClaudeMd setting.
|
||||
* When autoLoadClaudeMd is true:
|
||||
@@ -275,6 +433,9 @@ export interface CreateSdkOptionsConfig {
|
||||
|
||||
/** Allow unrestricted tools when MCP servers are enabled */
|
||||
mcpUnrestrictedTools?: boolean;
|
||||
|
||||
/** Extended thinking level for Claude models */
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
}
|
||||
|
||||
// Re-export MCP types from @automaker/types for convenience
|
||||
@@ -301,6 +462,9 @@ export function createSpecGenerationOptions(config: CreateSdkOptionsConfig): Opt
|
||||
// Build CLAUDE.md auto-loading options if enabled
|
||||
const claudeMdOptions = buildClaudeMdOptions(config);
|
||||
|
||||
// Build thinking options
|
||||
const thinkingOptions = buildThinkingOptions(config.thinkingLevel);
|
||||
|
||||
return {
|
||||
...getBaseOptions(),
|
||||
// Override permissionMode - spec generation only needs read-only tools
|
||||
@@ -312,6 +476,7 @@ export function createSpecGenerationOptions(config: CreateSdkOptionsConfig): Opt
|
||||
cwd: config.cwd,
|
||||
allowedTools: [...TOOL_PRESETS.specGeneration],
|
||||
...claudeMdOptions,
|
||||
...thinkingOptions,
|
||||
...(config.abortController && { abortController: config.abortController }),
|
||||
...(config.outputFormat && { outputFormat: config.outputFormat }),
|
||||
};
|
||||
@@ -333,6 +498,9 @@ export function createFeatureGenerationOptions(config: CreateSdkOptionsConfig):
|
||||
// Build CLAUDE.md auto-loading options if enabled
|
||||
const claudeMdOptions = buildClaudeMdOptions(config);
|
||||
|
||||
// Build thinking options
|
||||
const thinkingOptions = buildThinkingOptions(config.thinkingLevel);
|
||||
|
||||
return {
|
||||
...getBaseOptions(),
|
||||
// Override permissionMode - feature generation only needs read-only tools
|
||||
@@ -342,6 +510,7 @@ export function createFeatureGenerationOptions(config: CreateSdkOptionsConfig):
|
||||
cwd: config.cwd,
|
||||
allowedTools: [...TOOL_PRESETS.readOnly],
|
||||
...claudeMdOptions,
|
||||
...thinkingOptions,
|
||||
...(config.abortController && { abortController: config.abortController }),
|
||||
};
|
||||
}
|
||||
@@ -362,6 +531,9 @@ export function createSuggestionsOptions(config: CreateSdkOptionsConfig): Option
|
||||
// Build CLAUDE.md auto-loading options if enabled
|
||||
const claudeMdOptions = buildClaudeMdOptions(config);
|
||||
|
||||
// Build thinking options
|
||||
const thinkingOptions = buildThinkingOptions(config.thinkingLevel);
|
||||
|
||||
return {
|
||||
...getBaseOptions(),
|
||||
model: getModelForUseCase('suggestions', config.model),
|
||||
@@ -369,6 +541,7 @@ export function createSuggestionsOptions(config: CreateSdkOptionsConfig): Option
|
||||
cwd: config.cwd,
|
||||
allowedTools: [...TOOL_PRESETS.readOnly],
|
||||
...claudeMdOptions,
|
||||
...thinkingOptions,
|
||||
...(config.abortController && { abortController: config.abortController }),
|
||||
...(config.outputFormat && { outputFormat: config.outputFormat }),
|
||||
};
|
||||
@@ -381,7 +554,7 @@ export function createSuggestionsOptions(config: CreateSdkOptionsConfig): Option
|
||||
* - Full tool access for code modification
|
||||
* - Standard turns for interactive sessions
|
||||
* - Model priority: explicit model > session model > chat default
|
||||
* - Sandbox mode controlled by enableSandboxMode setting
|
||||
* - Sandbox mode controlled by enableSandboxMode setting (auto-disabled for cloud storage)
|
||||
* - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
|
||||
*/
|
||||
export function createChatOptions(config: CreateSdkOptionsConfig): Options {
|
||||
@@ -397,6 +570,12 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
|
||||
// Build MCP-related options
|
||||
const mcpOptions = buildMcpOptions(config);
|
||||
|
||||
// Build thinking options
|
||||
const thinkingOptions = buildThinkingOptions(config.thinkingLevel);
|
||||
|
||||
// Check sandbox compatibility (auto-disables for cloud storage paths)
|
||||
const sandboxCheck = checkSandboxCompatibility(config.cwd, config.enableSandboxMode);
|
||||
|
||||
return {
|
||||
...getBaseOptions(),
|
||||
model: getModelForUseCase('chat', effectiveModel),
|
||||
@@ -406,13 +585,14 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
|
||||
...(mcpOptions.shouldRestrictTools && { allowedTools: [...TOOL_PRESETS.chat] }),
|
||||
// Apply MCP bypass options if configured
|
||||
...mcpOptions.bypassOptions,
|
||||
...(config.enableSandboxMode && {
|
||||
...(sandboxCheck.enabled && {
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
autoAllowBashIfSandboxed: true,
|
||||
},
|
||||
}),
|
||||
...claudeMdOptions,
|
||||
...thinkingOptions,
|
||||
...(config.abortController && { abortController: config.abortController }),
|
||||
...mcpOptions.mcpServerOptions,
|
||||
};
|
||||
@@ -425,7 +605,7 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
|
||||
* - Full tool access for code modification and implementation
|
||||
* - Extended turns for thorough feature implementation
|
||||
* - Uses default model (can be overridden)
|
||||
* - Sandbox mode controlled by enableSandboxMode setting
|
||||
* - Sandbox mode controlled by enableSandboxMode setting (auto-disabled for cloud storage)
|
||||
* - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
|
||||
*/
|
||||
export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
|
||||
@@ -438,6 +618,12 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
|
||||
// Build MCP-related options
|
||||
const mcpOptions = buildMcpOptions(config);
|
||||
|
||||
// Build thinking options
|
||||
const thinkingOptions = buildThinkingOptions(config.thinkingLevel);
|
||||
|
||||
// Check sandbox compatibility (auto-disables for cloud storage paths)
|
||||
const sandboxCheck = checkSandboxCompatibility(config.cwd, config.enableSandboxMode);
|
||||
|
||||
return {
|
||||
...getBaseOptions(),
|
||||
model: getModelForUseCase('auto', config.model),
|
||||
@@ -447,13 +633,14 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
|
||||
...(mcpOptions.shouldRestrictTools && { allowedTools: [...TOOL_PRESETS.fullAccess] }),
|
||||
// Apply MCP bypass options if configured
|
||||
...mcpOptions.bypassOptions,
|
||||
...(config.enableSandboxMode && {
|
||||
...(sandboxCheck.enabled && {
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
autoAllowBashIfSandboxed: true,
|
||||
},
|
||||
}),
|
||||
...claudeMdOptions,
|
||||
...thinkingOptions,
|
||||
...(config.abortController && { abortController: config.abortController }),
|
||||
...mcpOptions.mcpServerOptions,
|
||||
};
|
||||
@@ -481,6 +668,9 @@ export function createCustomOptions(
|
||||
// Build MCP-related options
|
||||
const mcpOptions = buildMcpOptions(config);
|
||||
|
||||
// Build thinking options
|
||||
const thinkingOptions = buildThinkingOptions(config.thinkingLevel);
|
||||
|
||||
// For custom options: use explicit allowedTools if provided, otherwise use preset based on MCP settings
|
||||
const effectiveAllowedTools = config.allowedTools
|
||||
? [...config.allowedTools]
|
||||
@@ -498,6 +688,7 @@ export function createCustomOptions(
|
||||
// Apply MCP bypass options if configured
|
||||
...mcpOptions.bypassOptions,
|
||||
...claudeMdOptions,
|
||||
...thinkingOptions,
|
||||
...(config.abortController && { abortController: config.abortController }),
|
||||
...mcpOptions.mcpServerOptions,
|
||||
};
|
||||
|
||||
@@ -191,41 +191,6 @@ export async function getMCPServersFromSettings(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MCP permission settings from global settings.
|
||||
*
|
||||
* @param settingsService - Optional settings service instance
|
||||
* @param logPrefix - Prefix for log messages (e.g., '[AgentService]')
|
||||
* @returns Promise resolving to MCP permission settings
|
||||
*/
|
||||
export async function getMCPPermissionSettings(
|
||||
settingsService?: SettingsService | null,
|
||||
logPrefix = '[SettingsHelper]'
|
||||
): Promise<{ mcpAutoApproveTools: boolean; mcpUnrestrictedTools: boolean }> {
|
||||
// Default to true for autonomous workflow. Security is enforced when adding servers
|
||||
// via the security warning dialog that explains the risks.
|
||||
const defaults = { mcpAutoApproveTools: true, mcpUnrestrictedTools: true };
|
||||
|
||||
if (!settingsService) {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
try {
|
||||
const globalSettings = await settingsService.getGlobalSettings();
|
||||
const result = {
|
||||
mcpAutoApproveTools: globalSettings.mcpAutoApproveTools ?? true,
|
||||
mcpUnrestrictedTools: globalSettings.mcpUnrestrictedTools ?? true,
|
||||
};
|
||||
logger.info(
|
||||
`${logPrefix} MCP permission settings: autoApprove=${result.mcpAutoApproveTools}, unrestricted=${result.mcpUnrestrictedTools}`
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Failed to load MCP permission settings:`, error);
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a settings MCPServerConfig to SDK McpServerConfig format.
|
||||
* Validates required fields and throws informative errors if missing.
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('Version');
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
@@ -27,7 +30,7 @@ export function getVersion(): string {
|
||||
cachedVersion = version;
|
||||
return version;
|
||||
} catch (error) {
|
||||
console.warn('Failed to read version from package.json:', error);
|
||||
logger.warn('Failed to read version from package.json:', error);
|
||||
return '0.0.0';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
|
||||
import { query, type Options } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { BaseProvider } from './base-provider.js';
|
||||
import { classifyError, getUserFriendlyErrorMessage } from '@automaker/utils';
|
||||
import { classifyError, getUserFriendlyErrorMessage, createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('ClaudeProvider');
|
||||
import { getThinkingTokenBudget } from '@automaker/types';
|
||||
import type {
|
||||
ExecuteOptions,
|
||||
ProviderMessage,
|
||||
@@ -60,23 +63,20 @@ export class ClaudeProvider extends BaseProvider {
|
||||
abortController,
|
||||
conversationHistory,
|
||||
sdkSessionId,
|
||||
thinkingLevel,
|
||||
} = options;
|
||||
|
||||
// Convert thinking level to token budget
|
||||
const maxThinkingTokens = getThinkingTokenBudget(thinkingLevel);
|
||||
|
||||
// Build Claude SDK options
|
||||
// MCP permission logic - determines how to handle tool permissions when MCP servers are configured.
|
||||
// This logic mirrors buildMcpOptions() in sdk-options.ts but is applied here since
|
||||
// the provider is the final point where SDK options are constructed.
|
||||
// AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation
|
||||
const hasMcpServers = options.mcpServers && Object.keys(options.mcpServers).length > 0;
|
||||
// Default to true for autonomous workflow. Security is enforced when adding servers
|
||||
// via the security warning dialog that explains the risks.
|
||||
const mcpAutoApprove = options.mcpAutoApproveTools ?? true;
|
||||
const mcpUnrestricted = options.mcpUnrestrictedTools ?? true;
|
||||
const defaultTools = ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'];
|
||||
|
||||
// Determine permission mode based on settings
|
||||
const shouldBypassPermissions = hasMcpServers && mcpAutoApprove;
|
||||
// Determine if we should restrict tools (only when no MCP or unrestricted is disabled)
|
||||
const shouldRestrictTools = !hasMcpServers || !mcpUnrestricted;
|
||||
// AUTONOMOUS MODE: Always bypass permissions and allow unrestricted tools
|
||||
// Only restrict tools when no MCP servers are configured
|
||||
const shouldRestrictTools = !hasMcpServers;
|
||||
|
||||
const sdkOptions: Options = {
|
||||
model,
|
||||
@@ -88,10 +88,9 @@ export class ClaudeProvider extends BaseProvider {
|
||||
// Only restrict tools if explicitly set OR (no MCP / unrestricted disabled)
|
||||
...(allowedTools && shouldRestrictTools && { allowedTools }),
|
||||
...(!allowedTools && shouldRestrictTools && { allowedTools: defaultTools }),
|
||||
// When MCP servers are configured and auto-approve is enabled, use bypassPermissions
|
||||
permissionMode: shouldBypassPermissions ? 'bypassPermissions' : 'default',
|
||||
// Required when using bypassPermissions mode
|
||||
...(shouldBypassPermissions && { allowDangerouslySkipPermissions: true }),
|
||||
// AUTONOMOUS MODE: Always bypass permissions and allow dangerous operations
|
||||
permissionMode: 'bypassPermissions',
|
||||
allowDangerouslySkipPermissions: true,
|
||||
abortController,
|
||||
// Resume existing SDK session if we have a session ID
|
||||
...(sdkSessionId && conversationHistory && conversationHistory.length > 0
|
||||
@@ -103,6 +102,8 @@ export class ClaudeProvider extends BaseProvider {
|
||||
...(options.sandbox && { sandbox: options.sandbox }),
|
||||
// Forward MCP servers configuration
|
||||
...(options.mcpServers && { mcpServers: options.mcpServers }),
|
||||
// Extended thinking configuration
|
||||
...(maxThinkingTokens && { maxThinkingTokens }),
|
||||
};
|
||||
|
||||
// Build prompt payload
|
||||
@@ -140,7 +141,7 @@ export class ClaudeProvider extends BaseProvider {
|
||||
const errorInfo = classifyError(error);
|
||||
const userMessage = getUserFriendlyErrorMessage(error);
|
||||
|
||||
console.error('[ClaudeProvider] executeQuery() error during execution:', {
|
||||
logger.error('executeQuery() error during execution:', {
|
||||
type: errorInfo.type,
|
||||
message: errorInfo.message,
|
||||
isRateLimit: errorInfo.isRateLimit,
|
||||
|
||||
558
apps/server/src/providers/cli-provider.ts
Normal file
558
apps/server/src/providers/cli-provider.ts
Normal file
@@ -0,0 +1,558 @@
|
||||
/**
|
||||
* CliProvider - Abstract base class for CLI-based AI providers
|
||||
*
|
||||
* Provides common infrastructure for CLI tools that spawn subprocesses
|
||||
* and stream JSONL output. Handles:
|
||||
* - Platform-specific CLI detection (PATH, common locations)
|
||||
* - Windows execution strategies (WSL, npx, direct, cmd)
|
||||
* - JSONL subprocess spawning and streaming
|
||||
* - Error mapping infrastructure
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* class CursorProvider extends CliProvider {
|
||||
* getCliName(): string { return 'cursor-agent'; }
|
||||
* getSpawnConfig(): CliSpawnConfig {
|
||||
* return {
|
||||
* windowsStrategy: 'wsl',
|
||||
* commonPaths: {
|
||||
* linux: ['~/.local/bin/cursor-agent'],
|
||||
* darwin: ['~/.local/bin/cursor-agent'],
|
||||
* }
|
||||
* };
|
||||
* }
|
||||
* // ... implement abstract methods
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { BaseProvider } from './base-provider.js';
|
||||
import type { ProviderConfig, ExecuteOptions, ProviderMessage } from './types.js';
|
||||
import {
|
||||
spawnJSONLProcess,
|
||||
type SubprocessOptions,
|
||||
isWslAvailable,
|
||||
findCliInWsl,
|
||||
createWslCommand,
|
||||
windowsToWslPath,
|
||||
type WslCliResult,
|
||||
} from '@automaker/platform';
|
||||
import { createLogger, isAbortError } from '@automaker/utils';
|
||||
|
||||
/**
|
||||
* Spawn strategy for CLI tools on Windows
|
||||
*
|
||||
* Different CLI tools require different execution strategies:
|
||||
* - 'wsl': Requires WSL, CLI only available on Linux/macOS (e.g., cursor-agent)
|
||||
* - 'npx': Installed globally via npm/npx, use `npx <package>` to run
|
||||
* - 'direct': Native Windows binary, can spawn directly
|
||||
* - 'cmd': Windows batch file (.cmd/.bat), needs cmd.exe shell
|
||||
*/
|
||||
export type SpawnStrategy = 'wsl' | 'npx' | 'direct' | 'cmd';
|
||||
|
||||
/**
|
||||
* Configuration for CLI tool spawning
|
||||
*/
|
||||
export interface CliSpawnConfig {
|
||||
/** How to spawn on Windows */
|
||||
windowsStrategy: SpawnStrategy;
|
||||
|
||||
/** NPX package name (required if windowsStrategy is 'npx') */
|
||||
npxPackage?: string;
|
||||
|
||||
/** Preferred WSL distribution (if windowsStrategy is 'wsl') */
|
||||
wslDistribution?: string;
|
||||
|
||||
/**
|
||||
* Common installation paths per platform
|
||||
* Use ~ for home directory (will be expanded)
|
||||
* Keys: 'linux', 'darwin', 'win32'
|
||||
*/
|
||||
commonPaths: Record<string, string[]>;
|
||||
|
||||
/** Version check command (defaults to --version) */
|
||||
versionCommand?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI error information for consistent error handling
|
||||
*/
|
||||
export interface CliErrorInfo {
|
||||
code: string;
|
||||
message: string;
|
||||
recoverable: boolean;
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detection result from CLI path finding
|
||||
*/
|
||||
export interface CliDetectionResult {
|
||||
/** Path to the CLI (or 'npx' for npx strategy) */
|
||||
cliPath: string | null;
|
||||
/** Whether using WSL mode */
|
||||
useWsl: boolean;
|
||||
/** WSL path if using WSL */
|
||||
wslCliPath?: string;
|
||||
/** WSL distribution if using WSL */
|
||||
wslDistribution?: string;
|
||||
/** Detected strategy used */
|
||||
strategy: SpawnStrategy | 'native';
|
||||
}
|
||||
|
||||
// Create logger for CLI operations
|
||||
const cliLogger = createLogger('CliProvider');
|
||||
|
||||
/**
|
||||
* Abstract base class for CLI-based providers
|
||||
*
|
||||
* Subclasses must implement:
|
||||
* - getCliName(): CLI executable name
|
||||
* - getSpawnConfig(): Platform-specific spawn configuration
|
||||
* - buildCliArgs(): Convert ExecuteOptions to CLI arguments
|
||||
* - normalizeEvent(): Convert CLI output to ProviderMessage
|
||||
*/
|
||||
export abstract class CliProvider extends BaseProvider {
|
||||
// CLI detection results (cached after first detection)
|
||||
protected cliPath: string | null = null;
|
||||
protected useWsl: boolean = false;
|
||||
protected wslCliPath: string | null = null;
|
||||
protected wslDistribution: string | undefined = undefined;
|
||||
protected detectedStrategy: SpawnStrategy | 'native' = 'native';
|
||||
|
||||
// NPX args (used when strategy is 'npx')
|
||||
protected npxArgs: string[] = [];
|
||||
|
||||
constructor(config: ProviderConfig = {}) {
|
||||
super(config);
|
||||
// Detection happens lazily on first use
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Abstract methods - must be implemented by subclasses
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get the CLI executable name (e.g., 'cursor-agent', 'aider')
|
||||
*/
|
||||
abstract getCliName(): string;
|
||||
|
||||
/**
|
||||
* Get spawn configuration for this CLI
|
||||
*/
|
||||
abstract getSpawnConfig(): CliSpawnConfig;
|
||||
|
||||
/**
|
||||
* Build CLI arguments from execution options
|
||||
* @param options Execution options
|
||||
* @returns Array of CLI arguments
|
||||
*/
|
||||
abstract buildCliArgs(options: ExecuteOptions): string[];
|
||||
|
||||
/**
|
||||
* Normalize a raw CLI event to ProviderMessage format
|
||||
* @param event Raw event from CLI JSONL output
|
||||
* @returns Normalized ProviderMessage or null to skip
|
||||
*/
|
||||
abstract normalizeEvent(event: unknown): ProviderMessage | null;
|
||||
|
||||
// ==========================================================================
|
||||
// Optional overrides
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Map CLI stderr/exit code to error info
|
||||
* Override to provide CLI-specific error mapping
|
||||
*/
|
||||
protected mapError(stderr: string, exitCode: number | null): CliErrorInfo {
|
||||
const lower = stderr.toLowerCase();
|
||||
|
||||
// Common authentication errors
|
||||
if (
|
||||
lower.includes('not authenticated') ||
|
||||
lower.includes('please log in') ||
|
||||
lower.includes('unauthorized')
|
||||
) {
|
||||
return {
|
||||
code: 'NOT_AUTHENTICATED',
|
||||
message: `${this.getCliName()} is not authenticated`,
|
||||
recoverable: true,
|
||||
suggestion: `Run "${this.getCliName()} login" to authenticate`,
|
||||
};
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
if (
|
||||
lower.includes('rate limit') ||
|
||||
lower.includes('too many requests') ||
|
||||
lower.includes('429')
|
||||
) {
|
||||
return {
|
||||
code: 'RATE_LIMITED',
|
||||
message: 'API rate limit exceeded',
|
||||
recoverable: true,
|
||||
suggestion: 'Wait a few minutes and try again',
|
||||
};
|
||||
}
|
||||
|
||||
// Network errors
|
||||
if (
|
||||
lower.includes('network') ||
|
||||
lower.includes('connection') ||
|
||||
lower.includes('econnrefused') ||
|
||||
lower.includes('timeout')
|
||||
) {
|
||||
return {
|
||||
code: 'NETWORK_ERROR',
|
||||
message: 'Network connection error',
|
||||
recoverable: true,
|
||||
suggestion: 'Check your internet connection and try again',
|
||||
};
|
||||
}
|
||||
|
||||
// Process killed
|
||||
if (exitCode === 137 || lower.includes('killed') || lower.includes('sigterm')) {
|
||||
return {
|
||||
code: 'PROCESS_CRASHED',
|
||||
message: 'Process was terminated',
|
||||
recoverable: true,
|
||||
suggestion: 'The process may have run out of memory. Try a simpler task.',
|
||||
};
|
||||
}
|
||||
|
||||
// Generic error
|
||||
return {
|
||||
code: 'UNKNOWN_ERROR',
|
||||
message: stderr || `Process exited with code ${exitCode}`,
|
||||
recoverable: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get installation instructions for this CLI
|
||||
* Override to provide CLI-specific instructions
|
||||
*/
|
||||
protected getInstallInstructions(): string {
|
||||
const cliName = this.getCliName();
|
||||
const config = this.getSpawnConfig();
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
switch (config.windowsStrategy) {
|
||||
case 'wsl':
|
||||
return `${cliName} requires WSL on Windows. Install WSL, then run inside WSL to install.`;
|
||||
case 'npx':
|
||||
return `Install with: npm install -g ${config.npxPackage || cliName}`;
|
||||
case 'cmd':
|
||||
case 'direct':
|
||||
return `${cliName} is not installed. Check the documentation for installation instructions.`;
|
||||
}
|
||||
}
|
||||
|
||||
return `${cliName} is not installed. Check the documentation for installation instructions.`;
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// CLI Detection
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Expand ~ to home directory in path
|
||||
*/
|
||||
private expandPath(p: string): string {
|
||||
if (p.startsWith('~')) {
|
||||
return path.join(os.homedir(), p.slice(1));
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find CLI in PATH using 'which' (Unix) or 'where' (Windows)
|
||||
*/
|
||||
private findCliInPath(): string | null {
|
||||
const cliName = this.getCliName();
|
||||
|
||||
try {
|
||||
const command = process.platform === 'win32' ? 'where' : 'which';
|
||||
const result = execSync(`${command} ${cliName}`, {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
})
|
||||
.trim()
|
||||
.split('\n')[0];
|
||||
|
||||
if (result && fs.existsSync(result)) {
|
||||
cliLogger.debug(`Found ${cliName} in PATH: ${result}`);
|
||||
return result;
|
||||
}
|
||||
} catch {
|
||||
// Not in PATH
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find CLI in common installation paths for current platform
|
||||
*/
|
||||
private findCliInCommonPaths(): string | null {
|
||||
const config = this.getSpawnConfig();
|
||||
const cliName = this.getCliName();
|
||||
const platform = process.platform as 'linux' | 'darwin' | 'win32';
|
||||
const paths = config.commonPaths[platform] || [];
|
||||
|
||||
for (const p of paths) {
|
||||
const expandedPath = this.expandPath(p);
|
||||
if (fs.existsSync(expandedPath)) {
|
||||
cliLogger.debug(`Found ${cliName} at: ${expandedPath}`);
|
||||
return expandedPath;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect CLI installation using appropriate strategy
|
||||
*/
|
||||
protected detectCli(): CliDetectionResult {
|
||||
const config = this.getSpawnConfig();
|
||||
const cliName = this.getCliName();
|
||||
const wslLogger = (msg: string) => cliLogger.debug(msg);
|
||||
|
||||
// Windows - use configured strategy
|
||||
if (process.platform === 'win32') {
|
||||
switch (config.windowsStrategy) {
|
||||
case 'wsl': {
|
||||
// Check WSL for CLI
|
||||
if (isWslAvailable({ logger: wslLogger })) {
|
||||
const wslResult: WslCliResult | null = findCliInWsl(cliName, {
|
||||
logger: wslLogger,
|
||||
distribution: config.wslDistribution,
|
||||
});
|
||||
if (wslResult) {
|
||||
cliLogger.debug(
|
||||
`Using ${cliName} via WSL (${wslResult.distribution || 'default'}): ${wslResult.wslPath}`
|
||||
);
|
||||
return {
|
||||
cliPath: 'wsl.exe',
|
||||
useWsl: true,
|
||||
wslCliPath: wslResult.wslPath,
|
||||
wslDistribution: wslResult.distribution,
|
||||
strategy: 'wsl',
|
||||
};
|
||||
}
|
||||
}
|
||||
cliLogger.debug(`${cliName} not found (WSL not available or CLI not installed in WSL)`);
|
||||
return { cliPath: null, useWsl: false, strategy: 'wsl' };
|
||||
}
|
||||
|
||||
case 'npx': {
|
||||
// For npx, we don't need to find the CLI, just return npx
|
||||
cliLogger.debug(`Using ${cliName} via npx (package: ${config.npxPackage})`);
|
||||
return {
|
||||
cliPath: 'npx',
|
||||
useWsl: false,
|
||||
strategy: 'npx',
|
||||
};
|
||||
}
|
||||
|
||||
case 'direct':
|
||||
case 'cmd': {
|
||||
// Native Windows - check PATH and common paths
|
||||
const pathResult = this.findCliInPath();
|
||||
if (pathResult) {
|
||||
return { cliPath: pathResult, useWsl: false, strategy: config.windowsStrategy };
|
||||
}
|
||||
|
||||
const commonResult = this.findCliInCommonPaths();
|
||||
if (commonResult) {
|
||||
return { cliPath: commonResult, useWsl: false, strategy: config.windowsStrategy };
|
||||
}
|
||||
|
||||
cliLogger.debug(`${cliName} not found on Windows`);
|
||||
return { cliPath: null, useWsl: false, strategy: config.windowsStrategy };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Linux/macOS - native execution
|
||||
const pathResult = this.findCliInPath();
|
||||
if (pathResult) {
|
||||
return { cliPath: pathResult, useWsl: false, strategy: 'native' };
|
||||
}
|
||||
|
||||
const commonResult = this.findCliInCommonPaths();
|
||||
if (commonResult) {
|
||||
return { cliPath: commonResult, useWsl: false, strategy: 'native' };
|
||||
}
|
||||
|
||||
cliLogger.debug(`${cliName} not found`);
|
||||
return { cliPath: null, useWsl: false, strategy: 'native' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure CLI is detected (lazy initialization)
|
||||
*/
|
||||
protected ensureCliDetected(): void {
|
||||
if (this.cliPath !== null || this.detectedStrategy !== 'native') {
|
||||
return; // Already detected
|
||||
}
|
||||
|
||||
const result = this.detectCli();
|
||||
this.cliPath = result.cliPath;
|
||||
this.useWsl = result.useWsl;
|
||||
this.wslCliPath = result.wslCliPath || null;
|
||||
this.wslDistribution = result.wslDistribution;
|
||||
this.detectedStrategy = result.strategy;
|
||||
|
||||
// Set up npx args if using npx strategy
|
||||
const config = this.getSpawnConfig();
|
||||
if (result.strategy === 'npx' && config.npxPackage) {
|
||||
this.npxArgs = [config.npxPackage];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if CLI is installed
|
||||
*/
|
||||
async isInstalled(): Promise<boolean> {
|
||||
this.ensureCliDetected();
|
||||
return this.cliPath !== null;
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Subprocess Spawning
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Build subprocess options based on detected strategy
|
||||
*/
|
||||
protected buildSubprocessOptions(options: ExecuteOptions, cliArgs: string[]): SubprocessOptions {
|
||||
this.ensureCliDetected();
|
||||
|
||||
if (!this.cliPath) {
|
||||
throw new Error(`${this.getCliName()} CLI not found. ${this.getInstallInstructions()}`);
|
||||
}
|
||||
|
||||
const cwd = options.cwd || process.cwd();
|
||||
|
||||
// Filter undefined values from process.env
|
||||
const filteredEnv: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (value !== undefined) {
|
||||
filteredEnv[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// WSL strategy
|
||||
if (this.useWsl && this.wslCliPath) {
|
||||
const wslCwd = windowsToWslPath(cwd);
|
||||
const wslCmd = createWslCommand(this.wslCliPath, cliArgs, {
|
||||
distribution: this.wslDistribution,
|
||||
});
|
||||
|
||||
// Add --cd flag to change directory inside WSL
|
||||
let args: string[];
|
||||
if (this.wslDistribution) {
|
||||
args = ['-d', this.wslDistribution, '--cd', wslCwd, this.wslCliPath, ...cliArgs];
|
||||
} else {
|
||||
args = ['--cd', wslCwd, this.wslCliPath, ...cliArgs];
|
||||
}
|
||||
|
||||
cliLogger.debug(`WSL spawn: ${wslCmd.command} ${args.slice(0, 6).join(' ')}...`);
|
||||
|
||||
return {
|
||||
command: wslCmd.command,
|
||||
args,
|
||||
cwd, // Windows cwd for spawn
|
||||
env: filteredEnv,
|
||||
abortController: options.abortController,
|
||||
timeout: 120000, // CLI operations may take longer
|
||||
};
|
||||
}
|
||||
|
||||
// NPX strategy
|
||||
if (this.detectedStrategy === 'npx') {
|
||||
const allArgs = [...this.npxArgs, ...cliArgs];
|
||||
cliLogger.debug(`NPX spawn: npx ${allArgs.slice(0, 6).join(' ')}...`);
|
||||
|
||||
return {
|
||||
command: 'npx',
|
||||
args: allArgs,
|
||||
cwd,
|
||||
env: filteredEnv,
|
||||
abortController: options.abortController,
|
||||
timeout: 120000,
|
||||
};
|
||||
}
|
||||
|
||||
// Direct strategy (native Unix or Windows direct/cmd)
|
||||
cliLogger.debug(`Direct spawn: ${this.cliPath} ${cliArgs.slice(0, 6).join(' ')}...`);
|
||||
|
||||
return {
|
||||
command: this.cliPath,
|
||||
args: cliArgs,
|
||||
cwd,
|
||||
env: filteredEnv,
|
||||
abortController: options.abortController,
|
||||
timeout: 120000,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a query using the CLI with JSONL streaming
|
||||
*
|
||||
* This is a default implementation that:
|
||||
* 1. Builds CLI args from options
|
||||
* 2. Spawns the subprocess with appropriate strategy
|
||||
* 3. Streams and normalizes events
|
||||
*
|
||||
* Subclasses can override for custom behavior.
|
||||
*/
|
||||
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
|
||||
this.ensureCliDetected();
|
||||
|
||||
if (!this.cliPath) {
|
||||
throw new Error(`${this.getCliName()} CLI not found. ${this.getInstallInstructions()}`);
|
||||
}
|
||||
|
||||
const cliArgs = this.buildCliArgs(options);
|
||||
const subprocessOptions = this.buildSubprocessOptions(options, cliArgs);
|
||||
|
||||
try {
|
||||
for await (const rawEvent of spawnJSONLProcess(subprocessOptions)) {
|
||||
const normalized = this.normalizeEvent(rawEvent);
|
||||
if (normalized) {
|
||||
yield normalized;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
cliLogger.debug('Query aborted');
|
||||
return;
|
||||
}
|
||||
|
||||
// Map CLI errors
|
||||
if (error instanceof Error && 'stderr' in error) {
|
||||
const errorInfo = this.mapError(
|
||||
(error as { stderr?: string }).stderr || error.message,
|
||||
(error as { exitCode?: number | null }).exitCode ?? null
|
||||
);
|
||||
|
||||
const cliError = new Error(errorInfo.message) as Error & CliErrorInfo;
|
||||
cliError.code = errorInfo.code;
|
||||
cliError.recoverable = errorInfo.recoverable;
|
||||
cliError.suggestion = errorInfo.suggestion;
|
||||
throw cliError;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
197
apps/server/src/providers/cursor-config-manager.ts
Normal file
197
apps/server/src/providers/cursor-config-manager.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Cursor CLI Configuration Manager
|
||||
*
|
||||
* Manages Cursor CLI configuration stored in .automaker/cursor-config.json
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { getAllCursorModelIds, type CursorCliConfig, type CursorModelId } from '@automaker/types';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getAutomakerDir } from '@automaker/platform';
|
||||
|
||||
// Create logger for this module
|
||||
const logger = createLogger('CursorConfigManager');
|
||||
|
||||
/**
|
||||
* Manages Cursor CLI configuration
|
||||
* Config location: .automaker/cursor-config.json
|
||||
*/
|
||||
export class CursorConfigManager {
|
||||
private configPath: string;
|
||||
private config: CursorCliConfig;
|
||||
|
||||
constructor(projectPath: string) {
|
||||
// Use getAutomakerDir for consistent path resolution
|
||||
this.configPath = path.join(getAutomakerDir(projectPath), 'cursor-config.json');
|
||||
this.config = this.loadConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load configuration from disk
|
||||
*/
|
||||
private loadConfig(): CursorCliConfig {
|
||||
try {
|
||||
if (fs.existsSync(this.configPath)) {
|
||||
const content = fs.readFileSync(this.configPath, 'utf8');
|
||||
const parsed = JSON.parse(content) as CursorCliConfig;
|
||||
logger.debug(`Loaded config from ${this.configPath}`);
|
||||
return parsed;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to load config:', error);
|
||||
}
|
||||
|
||||
// Return default config with all available models
|
||||
return {
|
||||
defaultModel: 'auto',
|
||||
models: getAllCursorModelIds(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save configuration to disk
|
||||
*/
|
||||
private saveConfig(): void {
|
||||
try {
|
||||
const dir = path.dirname(this.configPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(this.configPath, JSON.stringify(this.config, null, 2));
|
||||
logger.debug('Config saved');
|
||||
} catch (error) {
|
||||
logger.error('Failed to save config:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full configuration
|
||||
*/
|
||||
getConfig(): CursorCliConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default model
|
||||
*/
|
||||
getDefaultModel(): CursorModelId {
|
||||
return this.config.defaultModel || 'auto';
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the default model
|
||||
*/
|
||||
setDefaultModel(model: CursorModelId): void {
|
||||
this.config.defaultModel = model;
|
||||
this.saveConfig();
|
||||
logger.info(`Default model set to: ${model}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get enabled models
|
||||
*/
|
||||
getEnabledModels(): CursorModelId[] {
|
||||
return this.config.models || ['auto'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set enabled models
|
||||
*/
|
||||
setEnabledModels(models: CursorModelId[]): void {
|
||||
this.config.models = models;
|
||||
this.saveConfig();
|
||||
logger.info(`Enabled models updated: ${models.join(', ')}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a model to enabled list
|
||||
*/
|
||||
addModel(model: CursorModelId): void {
|
||||
if (!this.config.models) {
|
||||
this.config.models = [];
|
||||
}
|
||||
if (!this.config.models.includes(model)) {
|
||||
this.config.models.push(model);
|
||||
this.saveConfig();
|
||||
logger.info(`Model added: ${model}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a model from enabled list
|
||||
*/
|
||||
removeModel(model: CursorModelId): void {
|
||||
if (this.config.models) {
|
||||
this.config.models = this.config.models.filter((m) => m !== model);
|
||||
this.saveConfig();
|
||||
logger.info(`Model removed: ${model}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model is enabled
|
||||
*/
|
||||
isModelEnabled(model: CursorModelId): boolean {
|
||||
return this.config.models?.includes(model) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MCP server configurations
|
||||
*/
|
||||
getMcpServers(): string[] {
|
||||
return this.config.mcpServers || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set MCP server configurations
|
||||
*/
|
||||
setMcpServers(servers: string[]): void {
|
||||
this.config.mcpServers = servers;
|
||||
this.saveConfig();
|
||||
logger.info(`MCP servers updated: ${servers.join(', ')}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Cursor rules paths
|
||||
*/
|
||||
getRules(): string[] {
|
||||
return this.config.rules || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set Cursor rules paths
|
||||
*/
|
||||
setRules(rules: string[]): void {
|
||||
this.config.rules = rules;
|
||||
this.saveConfig();
|
||||
logger.info(`Rules updated: ${rules.join(', ')}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset configuration to defaults
|
||||
*/
|
||||
reset(): void {
|
||||
this.config = {
|
||||
defaultModel: 'auto',
|
||||
models: getAllCursorModelIds(),
|
||||
};
|
||||
this.saveConfig();
|
||||
logger.info('Config reset to defaults');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if config file exists
|
||||
*/
|
||||
exists(): boolean {
|
||||
return fs.existsSync(this.configPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the config file path
|
||||
*/
|
||||
getConfigPath(): string {
|
||||
return this.configPath;
|
||||
}
|
||||
}
|
||||
993
apps/server/src/providers/cursor-provider.ts
Normal file
993
apps/server/src/providers/cursor-provider.ts
Normal file
@@ -0,0 +1,993 @@
|
||||
/**
|
||||
* Cursor Provider - Executes queries using cursor-agent CLI
|
||||
*
|
||||
* Extends CliProvider with Cursor-specific:
|
||||
* - Event normalization for Cursor's JSONL format
|
||||
* - Text block deduplication (Cursor sends duplicates)
|
||||
* - Session ID tracking
|
||||
* - Versions directory detection
|
||||
*
|
||||
* Spawns the cursor-agent CLI with --output-format stream-json for streaming responses.
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import {
|
||||
CliProvider,
|
||||
type CliSpawnConfig,
|
||||
type CliDetectionResult,
|
||||
type CliErrorInfo,
|
||||
} from './cli-provider.js';
|
||||
import type {
|
||||
ProviderConfig,
|
||||
ExecuteOptions,
|
||||
ProviderMessage,
|
||||
InstallationStatus,
|
||||
ModelDefinition,
|
||||
ContentBlock,
|
||||
} from './types.js';
|
||||
import { stripProviderPrefix } from '@automaker/types';
|
||||
import {
|
||||
type CursorStreamEvent,
|
||||
type CursorSystemEvent,
|
||||
type CursorAssistantEvent,
|
||||
type CursorToolCallEvent,
|
||||
type CursorResultEvent,
|
||||
type CursorAuthStatus,
|
||||
CURSOR_MODEL_MAP,
|
||||
} from '@automaker/types';
|
||||
import { createLogger, isAbortError } from '@automaker/utils';
|
||||
import { spawnJSONLProcess, execInWsl } from '@automaker/platform';
|
||||
|
||||
// Create logger for this module
|
||||
const logger = createLogger('CursorProvider');
|
||||
|
||||
// =============================================================================
|
||||
// Cursor Tool Handler Registry
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Tool handler definition for mapping Cursor tool calls to normalized format
|
||||
*/
|
||||
interface CursorToolHandler<TArgs = unknown, TResult = unknown> {
|
||||
/** The normalized tool name (e.g., 'Read', 'Write') */
|
||||
name: string;
|
||||
/** Extract and normalize input from Cursor's args format */
|
||||
mapInput: (args: TArgs) => unknown;
|
||||
/** Format the result content for display (optional) */
|
||||
formatResult?: (result: TResult, args?: TArgs) => string;
|
||||
/** Format rejected result (optional) */
|
||||
formatRejected?: (reason: string) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of Cursor tool handlers
|
||||
* Each handler knows how to normalize its specific tool call type
|
||||
*/
|
||||
const CURSOR_TOOL_HANDLERS: Record<string, CursorToolHandler<any, any>> = {
|
||||
readToolCall: {
|
||||
name: 'Read',
|
||||
mapInput: (args: { path: string }) => ({ file_path: args.path }),
|
||||
formatResult: (result: { content: string }) => result.content,
|
||||
},
|
||||
|
||||
writeToolCall: {
|
||||
name: 'Write',
|
||||
mapInput: (args: { path: string; fileText: string }) => ({
|
||||
file_path: args.path,
|
||||
content: args.fileText,
|
||||
}),
|
||||
formatResult: (result: { linesCreated: number; path: string }) =>
|
||||
`Wrote ${result.linesCreated} lines to ${result.path}`,
|
||||
},
|
||||
|
||||
editToolCall: {
|
||||
name: 'Edit',
|
||||
mapInput: (args: { path: string; oldText?: string; newText?: string }) => ({
|
||||
file_path: args.path,
|
||||
old_string: args.oldText,
|
||||
new_string: args.newText,
|
||||
}),
|
||||
formatResult: (_result: unknown, args?: { path: string }) => `Edited file: ${args?.path}`,
|
||||
},
|
||||
|
||||
shellToolCall: {
|
||||
name: 'Bash',
|
||||
mapInput: (args: { command: string }) => ({ command: args.command }),
|
||||
formatResult: (result: { exitCode: number; stdout?: string; stderr?: string }) => {
|
||||
let content = `Exit code: ${result.exitCode}`;
|
||||
if (result.stdout) content += `\n${result.stdout}`;
|
||||
if (result.stderr) content += `\nStderr: ${result.stderr}`;
|
||||
return content;
|
||||
},
|
||||
formatRejected: (reason: string) => `Rejected: ${reason}`,
|
||||
},
|
||||
|
||||
deleteToolCall: {
|
||||
name: 'Delete',
|
||||
mapInput: (args: { path: string }) => ({ file_path: args.path }),
|
||||
formatResult: (_result: unknown, args?: { path: string }) => `Deleted: ${args?.path}`,
|
||||
formatRejected: (reason: string) => `Delete rejected: ${reason}`,
|
||||
},
|
||||
|
||||
grepToolCall: {
|
||||
name: 'Grep',
|
||||
mapInput: (args: { pattern: string; path?: string }) => ({
|
||||
pattern: args.pattern,
|
||||
path: args.path,
|
||||
}),
|
||||
formatResult: (result: { matchedLines: number }) =>
|
||||
`Found ${result.matchedLines} matching lines`,
|
||||
},
|
||||
|
||||
lsToolCall: {
|
||||
name: 'Ls',
|
||||
mapInput: (args: { path: string }) => ({ path: args.path }),
|
||||
formatResult: (result: { childrenFiles: number; childrenDirs: number }) =>
|
||||
`Found ${result.childrenFiles} files, ${result.childrenDirs} directories`,
|
||||
},
|
||||
|
||||
globToolCall: {
|
||||
name: 'Glob',
|
||||
mapInput: (args: { globPattern: string; targetDirectory?: string }) => ({
|
||||
pattern: args.globPattern,
|
||||
path: args.targetDirectory,
|
||||
}),
|
||||
formatResult: (result: { totalFiles: number }) => `Found ${result.totalFiles} matching files`,
|
||||
},
|
||||
|
||||
semSearchToolCall: {
|
||||
name: 'SemanticSearch',
|
||||
mapInput: (args: { query: string; targetDirectories?: string[]; explanation?: string }) => ({
|
||||
query: args.query,
|
||||
targetDirectories: args.targetDirectories,
|
||||
explanation: args.explanation,
|
||||
}),
|
||||
formatResult: (result: { results: string; codeResults?: unknown[] }) => {
|
||||
const resultCount = result.codeResults?.length || 0;
|
||||
return resultCount > 0
|
||||
? `Found ${resultCount} semantic search result(s)`
|
||||
: result.results || 'No results found';
|
||||
},
|
||||
},
|
||||
|
||||
readLintsToolCall: {
|
||||
name: 'ReadLints',
|
||||
mapInput: (args: { paths: string[] }) => ({ paths: args.paths }),
|
||||
formatResult: (result: { totalDiagnostics: number; totalFiles: number }) =>
|
||||
`Found ${result.totalDiagnostics} diagnostic(s) in ${result.totalFiles} file(s)`,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Process a Cursor tool call using the handler registry
|
||||
* Returns { toolName, toolInput } or null if tool type is unknown
|
||||
*/
|
||||
function processCursorToolCall(
|
||||
toolCall: CursorToolCallEvent['tool_call']
|
||||
): { toolName: string; toolInput: unknown } | null {
|
||||
// Check each registered handler
|
||||
for (const [key, handler] of Object.entries(CURSOR_TOOL_HANDLERS)) {
|
||||
const toolData = toolCall[key as keyof typeof toolCall] as { args?: unknown } | undefined;
|
||||
if (toolData) {
|
||||
// Skip if args not yet populated (partial streaming event)
|
||||
if (!toolData.args) return null;
|
||||
return {
|
||||
toolName: handler.name,
|
||||
toolInput: handler.mapInput(toolData.args),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Handle generic function call (fallback)
|
||||
if (toolCall.function) {
|
||||
let toolInput: unknown;
|
||||
try {
|
||||
toolInput = JSON.parse(toolCall.function.arguments || '{}');
|
||||
} catch {
|
||||
toolInput = { raw: toolCall.function.arguments };
|
||||
}
|
||||
return {
|
||||
toolName: toolCall.function.name,
|
||||
toolInput,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the result content for a completed Cursor tool call
|
||||
*/
|
||||
function formatCursorToolResult(toolCall: CursorToolCallEvent['tool_call']): string {
|
||||
for (const [key, handler] of Object.entries(CURSOR_TOOL_HANDLERS)) {
|
||||
const toolData = toolCall[key as keyof typeof toolCall] as
|
||||
| {
|
||||
args?: unknown;
|
||||
result?: { success?: unknown; rejected?: { reason: string } };
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (toolData?.result) {
|
||||
if (toolData.result.success && handler.formatResult) {
|
||||
return handler.formatResult(toolData.result.success, toolData.args);
|
||||
}
|
||||
if (toolData.result.rejected && handler.formatRejected) {
|
||||
return handler.formatRejected(toolData.result.rejected.reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Error Codes
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Cursor-specific error codes for detailed error handling
|
||||
*/
|
||||
export enum CursorErrorCode {
|
||||
NOT_INSTALLED = 'CURSOR_NOT_INSTALLED',
|
||||
NOT_AUTHENTICATED = 'CURSOR_NOT_AUTHENTICATED',
|
||||
RATE_LIMITED = 'CURSOR_RATE_LIMITED',
|
||||
MODEL_UNAVAILABLE = 'CURSOR_MODEL_UNAVAILABLE',
|
||||
NETWORK_ERROR = 'CURSOR_NETWORK_ERROR',
|
||||
PROCESS_CRASHED = 'CURSOR_PROCESS_CRASHED',
|
||||
TIMEOUT = 'CURSOR_TIMEOUT',
|
||||
UNKNOWN = 'CURSOR_UNKNOWN_ERROR',
|
||||
}
|
||||
|
||||
export interface CursorError extends Error {
|
||||
code: CursorErrorCode;
|
||||
recoverable: boolean;
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* CursorProvider - Integrates cursor-agent CLI as an AI provider
|
||||
*
|
||||
* Extends CliProvider with Cursor-specific behavior:
|
||||
* - WSL required on Windows (cursor-agent has no native Windows build)
|
||||
* - Versions directory detection for cursor-agent installations
|
||||
* - Session ID tracking for conversation continuity
|
||||
* - Text block deduplication (Cursor sends duplicate chunks)
|
||||
*/
|
||||
export class CursorProvider extends CliProvider {
|
||||
/**
|
||||
* Version data directory where cursor-agent stores versions
|
||||
* The install script creates versioned folders like:
|
||||
* ~/.local/share/cursor-agent/versions/2025.12.17-996666f/cursor-agent
|
||||
*/
|
||||
private static VERSIONS_DIR = path.join(os.homedir(), '.local/share/cursor-agent/versions');
|
||||
|
||||
constructor(config: ProviderConfig = {}) {
|
||||
super(config);
|
||||
// Trigger CLI detection on construction (eager for Cursor)
|
||||
this.ensureCliDetected();
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// CliProvider Abstract Method Implementations
|
||||
// ==========================================================================
|
||||
|
||||
getName(): string {
|
||||
return 'cursor';
|
||||
}
|
||||
|
||||
getCliName(): string {
|
||||
return 'cursor-agent';
|
||||
}
|
||||
|
||||
getSpawnConfig(): CliSpawnConfig {
|
||||
return {
|
||||
windowsStrategy: 'wsl', // cursor-agent requires WSL on Windows
|
||||
commonPaths: {
|
||||
linux: [
|
||||
path.join(os.homedir(), '.local/bin/cursor-agent'), // Primary symlink location
|
||||
'/usr/local/bin/cursor-agent',
|
||||
],
|
||||
darwin: [path.join(os.homedir(), '.local/bin/cursor-agent'), '/usr/local/bin/cursor-agent'],
|
||||
// Windows paths are not used - we check for WSL installation instead
|
||||
win32: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract prompt text from ExecuteOptions
|
||||
* Used to pass prompt via stdin instead of CLI args to avoid shell escaping issues
|
||||
*/
|
||||
private extractPromptText(options: ExecuteOptions): string {
|
||||
if (typeof options.prompt === 'string') {
|
||||
return options.prompt;
|
||||
} else if (Array.isArray(options.prompt)) {
|
||||
return options.prompt
|
||||
.filter((p) => p.type === 'text' && p.text)
|
||||
.map((p) => p.text)
|
||||
.join('\n');
|
||||
} else {
|
||||
throw new Error('Invalid prompt format');
|
||||
}
|
||||
}
|
||||
|
||||
buildCliArgs(options: ExecuteOptions): string[] {
|
||||
// Extract model (strip 'cursor-' prefix if present)
|
||||
const model = stripProviderPrefix(options.model || 'auto');
|
||||
|
||||
// Build CLI arguments for cursor-agent
|
||||
// NOTE: Prompt is NOT included here - it's passed via stdin to avoid
|
||||
// shell escaping issues when content contains $(), backticks, etc.
|
||||
const cliArgs: string[] = [
|
||||
'-p', // Print mode (non-interactive)
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--stream-partial-output', // Real-time streaming
|
||||
];
|
||||
|
||||
// Only add --force if NOT in read-only mode
|
||||
// Without --force, Cursor CLI suggests changes but doesn't apply them
|
||||
// With --force, Cursor CLI can actually edit files
|
||||
if (!options.readOnly) {
|
||||
cliArgs.push('--force');
|
||||
}
|
||||
|
||||
// Add model if not auto
|
||||
if (model !== 'auto') {
|
||||
cliArgs.push('--model', model);
|
||||
}
|
||||
|
||||
// Use '-' to indicate reading prompt from stdin
|
||||
cliArgs.push('-');
|
||||
|
||||
return cliArgs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Cursor event to AutoMaker ProviderMessage format
|
||||
* Made public as required by CliProvider abstract method
|
||||
*/
|
||||
normalizeEvent(event: unknown): ProviderMessage | null {
|
||||
const cursorEvent = event as CursorStreamEvent;
|
||||
|
||||
switch (cursorEvent.type) {
|
||||
case 'system':
|
||||
// System init - we capture session_id but don't yield a message
|
||||
return null;
|
||||
|
||||
case 'user':
|
||||
// User message - already handled by caller
|
||||
return null;
|
||||
|
||||
case 'assistant': {
|
||||
const assistantEvent = cursorEvent as CursorAssistantEvent;
|
||||
return {
|
||||
type: 'assistant',
|
||||
session_id: assistantEvent.session_id,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: assistantEvent.message.content.map((c) => ({
|
||||
type: 'text' as const,
|
||||
text: c.text,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'tool_call': {
|
||||
const toolEvent = cursorEvent as CursorToolCallEvent;
|
||||
const toolCall = toolEvent.tool_call;
|
||||
|
||||
// Use the tool handler registry to process the tool call
|
||||
const processed = processCursorToolCall(toolCall);
|
||||
if (!processed) {
|
||||
// Log unrecognized tool call structure for debugging
|
||||
const toolCallKeys = Object.keys(toolCall);
|
||||
logger.warn(
|
||||
`[UNHANDLED TOOL_CALL] Unknown tool call structure. Keys: ${toolCallKeys.join(', ')}. ` +
|
||||
`Full tool_call: ${JSON.stringify(toolCall).substring(0, 500)}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const { toolName, toolInput } = processed;
|
||||
|
||||
// For started events, emit tool_use
|
||||
if (toolEvent.subtype === 'started') {
|
||||
return {
|
||||
type: 'assistant',
|
||||
session_id: toolEvent.session_id,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
name: toolName,
|
||||
tool_use_id: toolEvent.call_id,
|
||||
input: toolInput,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// For completed events, emit both tool_use and tool_result
|
||||
if (toolEvent.subtype === 'completed') {
|
||||
const resultContent = formatCursorToolResult(toolCall);
|
||||
|
||||
return {
|
||||
type: 'assistant',
|
||||
session_id: toolEvent.session_id,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
name: toolName,
|
||||
tool_use_id: toolEvent.call_id,
|
||||
input: toolInput,
|
||||
},
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: toolEvent.call_id,
|
||||
content: resultContent,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'result': {
|
||||
const resultEvent = cursorEvent as CursorResultEvent;
|
||||
|
||||
if (resultEvent.is_error) {
|
||||
return {
|
||||
type: 'error',
|
||||
session_id: resultEvent.session_id,
|
||||
error: resultEvent.error || resultEvent.result || 'Unknown error',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
session_id: resultEvent.session_id,
|
||||
result: resultEvent.result,
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// CliProvider Overrides
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Override CLI detection to add Cursor-specific versions directory check
|
||||
*/
|
||||
protected detectCli(): CliDetectionResult {
|
||||
// First try standard detection (PATH, common paths, WSL)
|
||||
const result = super.detectCli();
|
||||
if (result.cliPath) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Cursor-specific: Check versions directory for any installed version
|
||||
// This handles cases where cursor-agent is installed but not in PATH
|
||||
if (process.platform !== 'win32' && fs.existsSync(CursorProvider.VERSIONS_DIR)) {
|
||||
try {
|
||||
const versions = fs
|
||||
.readdirSync(CursorProvider.VERSIONS_DIR)
|
||||
.filter((v) => !v.startsWith('.'))
|
||||
.sort()
|
||||
.reverse(); // Most recent first
|
||||
|
||||
for (const version of versions) {
|
||||
const versionPath = path.join(CursorProvider.VERSIONS_DIR, version, 'cursor-agent');
|
||||
if (fs.existsSync(versionPath)) {
|
||||
logger.debug(`Found cursor-agent version ${version} at: ${versionPath}`);
|
||||
return {
|
||||
cliPath: versionPath,
|
||||
useWsl: false,
|
||||
strategy: 'native',
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore directory read errors
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override error mapping for Cursor-specific error codes
|
||||
*/
|
||||
protected mapError(stderr: string, exitCode: number | null): CliErrorInfo {
|
||||
const lower = stderr.toLowerCase();
|
||||
|
||||
if (
|
||||
lower.includes('not authenticated') ||
|
||||
lower.includes('please log in') ||
|
||||
lower.includes('unauthorized')
|
||||
) {
|
||||
return {
|
||||
code: CursorErrorCode.NOT_AUTHENTICATED,
|
||||
message: 'Cursor CLI is not authenticated',
|
||||
recoverable: true,
|
||||
suggestion: 'Run "cursor-agent login" to authenticate with your browser',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
lower.includes('rate limit') ||
|
||||
lower.includes('too many requests') ||
|
||||
lower.includes('429')
|
||||
) {
|
||||
return {
|
||||
code: CursorErrorCode.RATE_LIMITED,
|
||||
message: 'Cursor API rate limit exceeded',
|
||||
recoverable: true,
|
||||
suggestion: 'Wait a few minutes and try again, or upgrade to Cursor Pro',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
lower.includes('model not available') ||
|
||||
lower.includes('invalid model') ||
|
||||
lower.includes('unknown model')
|
||||
) {
|
||||
return {
|
||||
code: CursorErrorCode.MODEL_UNAVAILABLE,
|
||||
message: 'Requested model is not available',
|
||||
recoverable: true,
|
||||
suggestion: 'Try using "auto" mode or select a different model',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
lower.includes('network') ||
|
||||
lower.includes('connection') ||
|
||||
lower.includes('econnrefused') ||
|
||||
lower.includes('timeout')
|
||||
) {
|
||||
return {
|
||||
code: CursorErrorCode.NETWORK_ERROR,
|
||||
message: 'Network connection error',
|
||||
recoverable: true,
|
||||
suggestion: 'Check your internet connection and try again',
|
||||
};
|
||||
}
|
||||
|
||||
if (exitCode === 137 || lower.includes('killed') || lower.includes('sigterm')) {
|
||||
return {
|
||||
code: CursorErrorCode.PROCESS_CRASHED,
|
||||
message: 'Cursor agent process was terminated',
|
||||
recoverable: true,
|
||||
suggestion: 'The process may have run out of memory. Try a simpler task.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
code: CursorErrorCode.UNKNOWN,
|
||||
message: stderr || `Cursor agent exited with code ${exitCode}`,
|
||||
recoverable: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Override install instructions for Cursor-specific guidance
|
||||
*/
|
||||
protected getInstallInstructions(): string {
|
||||
if (process.platform === 'win32') {
|
||||
return 'cursor-agent requires WSL on Windows. Install WSL, then run in WSL: curl https://cursor.com/install -fsS | bash';
|
||||
}
|
||||
return 'Install with: curl https://cursor.com/install -fsS | bash';
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a prompt using Cursor CLI with streaming
|
||||
*
|
||||
* Overrides base class to add:
|
||||
* - Session ID tracking from system init events
|
||||
* - Text block deduplication (Cursor sends duplicate chunks)
|
||||
*/
|
||||
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
|
||||
this.ensureCliDetected();
|
||||
|
||||
if (!this.cliPath) {
|
||||
throw this.createError(
|
||||
CursorErrorCode.NOT_INSTALLED,
|
||||
'Cursor CLI is not installed',
|
||||
true,
|
||||
this.getInstallInstructions()
|
||||
);
|
||||
}
|
||||
|
||||
// MCP servers are not yet supported by Cursor CLI - log warning but continue
|
||||
if (options.mcpServers && Object.keys(options.mcpServers).length > 0) {
|
||||
const serverCount = Object.keys(options.mcpServers).length;
|
||||
logger.warn(
|
||||
`MCP servers configured (${serverCount}) but not yet supported by Cursor CLI in AutoMaker. ` +
|
||||
`MCP support for Cursor will be added in a future release. ` +
|
||||
`The configured MCP servers will be ignored for this execution.`
|
||||
);
|
||||
}
|
||||
|
||||
// Extract prompt text to pass via stdin (avoids shell escaping issues)
|
||||
const promptText = this.extractPromptText(options);
|
||||
|
||||
const cliArgs = this.buildCliArgs(options);
|
||||
const subprocessOptions = this.buildSubprocessOptions(options, cliArgs);
|
||||
|
||||
// Pass prompt via stdin to avoid shell interpretation of special characters
|
||||
// like $(), backticks, etc. that may appear in file content
|
||||
subprocessOptions.stdinData = promptText;
|
||||
|
||||
let sessionId: string | undefined;
|
||||
|
||||
// Dedup state for Cursor-specific text block handling
|
||||
let lastTextBlock = '';
|
||||
let accumulatedText = '';
|
||||
|
||||
logger.debug(`CursorProvider.executeQuery called with model: "${options.model}"`);
|
||||
|
||||
// Debug: log raw events when AUTOMAKER_DEBUG_RAW_OUTPUT is enabled
|
||||
const debugRawEvents =
|
||||
process.env.AUTOMAKER_DEBUG_RAW_OUTPUT === 'true' ||
|
||||
process.env.AUTOMAKER_DEBUG_RAW_OUTPUT === '1';
|
||||
|
||||
try {
|
||||
for await (const rawEvent of spawnJSONLProcess(subprocessOptions)) {
|
||||
const event = rawEvent as CursorStreamEvent;
|
||||
|
||||
// Log raw event for debugging
|
||||
if (debugRawEvents) {
|
||||
const subtype = 'subtype' in event ? (event.subtype as string) : 'none';
|
||||
logger.info(`[RAW EVENT] type=${event.type} subtype=${subtype}`);
|
||||
if (event.type === 'tool_call') {
|
||||
const toolEvent = event as CursorToolCallEvent;
|
||||
const tc = toolEvent.tool_call;
|
||||
const toolTypes =
|
||||
[
|
||||
tc.readToolCall && 'read',
|
||||
tc.writeToolCall && 'write',
|
||||
tc.editToolCall && 'edit',
|
||||
tc.shellToolCall && 'shell',
|
||||
tc.deleteToolCall && 'delete',
|
||||
tc.grepToolCall && 'grep',
|
||||
tc.lsToolCall && 'ls',
|
||||
tc.globToolCall && 'glob',
|
||||
tc.function && `function:${tc.function.name}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(',') || 'unknown';
|
||||
logger.info(
|
||||
`[RAW TOOL_CALL] call_id=${toolEvent.call_id} types=[${toolTypes}]` +
|
||||
(tc.shellToolCall ? ` cmd="${tc.shellToolCall.args?.command}"` : '') +
|
||||
(tc.writeToolCall ? ` path="${tc.writeToolCall.args?.path}"` : '')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Capture session ID from system init
|
||||
if (event.type === 'system' && (event as CursorSystemEvent).subtype === 'init') {
|
||||
sessionId = event.session_id;
|
||||
logger.debug(`Session started: ${sessionId}`);
|
||||
}
|
||||
|
||||
// Normalize and yield the event
|
||||
const normalized = this.normalizeEvent(event);
|
||||
if (!normalized && debugRawEvents) {
|
||||
logger.info(`[DROPPED EVENT] type=${event.type} - normalizeEvent returned null`);
|
||||
}
|
||||
if (normalized) {
|
||||
// Ensure session_id is always set
|
||||
if (!normalized.session_id && sessionId) {
|
||||
normalized.session_id = sessionId;
|
||||
}
|
||||
|
||||
// Apply Cursor-specific dedup for assistant text messages
|
||||
if (normalized.type === 'assistant' && normalized.message?.content) {
|
||||
const dedupedContent = this.deduplicateTextBlocks(
|
||||
normalized.message.content,
|
||||
lastTextBlock,
|
||||
accumulatedText
|
||||
);
|
||||
|
||||
if (dedupedContent.content.length === 0) {
|
||||
// All blocks were duplicates, skip this message
|
||||
continue;
|
||||
}
|
||||
|
||||
// Update state
|
||||
lastTextBlock = dedupedContent.lastBlock;
|
||||
accumulatedText = dedupedContent.accumulated;
|
||||
|
||||
// Update the message with deduped content
|
||||
normalized.message.content = dedupedContent.content;
|
||||
}
|
||||
|
||||
yield normalized;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
logger.debug('Query aborted');
|
||||
return;
|
||||
}
|
||||
|
||||
// Map CLI errors to CursorError
|
||||
if (error instanceof Error && 'stderr' in error) {
|
||||
const errorInfo = this.mapError(
|
||||
(error as { stderr?: string }).stderr || error.message,
|
||||
(error as { exitCode?: number | null }).exitCode ?? null
|
||||
);
|
||||
throw this.createError(
|
||||
errorInfo.code as CursorErrorCode,
|
||||
errorInfo.message,
|
||||
errorInfo.recoverable,
|
||||
errorInfo.suggestion
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Cursor-Specific Methods
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Create a CursorError with details
|
||||
*/
|
||||
private createError(
|
||||
code: CursorErrorCode,
|
||||
message: string,
|
||||
recoverable: boolean = false,
|
||||
suggestion?: string
|
||||
): CursorError {
|
||||
const error = new Error(message) as CursorError;
|
||||
error.code = code;
|
||||
error.recoverable = recoverable;
|
||||
error.suggestion = suggestion;
|
||||
error.name = 'CursorError';
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicate text blocks in Cursor assistant messages
|
||||
*
|
||||
* Cursor often sends:
|
||||
* 1. Duplicate consecutive text blocks (same text twice in a row)
|
||||
* 2. A final accumulated block containing ALL previous text
|
||||
*
|
||||
* This method filters out these duplicates to prevent UI stuttering.
|
||||
*/
|
||||
private deduplicateTextBlocks(
|
||||
content: ContentBlock[],
|
||||
lastTextBlock: string,
|
||||
accumulatedText: string
|
||||
): { content: ContentBlock[]; lastBlock: string; accumulated: string } {
|
||||
const filtered: ContentBlock[] = [];
|
||||
let newLastBlock = lastTextBlock;
|
||||
let newAccumulated = accumulatedText;
|
||||
|
||||
for (const block of content) {
|
||||
if (block.type !== 'text' || !block.text) {
|
||||
filtered.push(block);
|
||||
continue;
|
||||
}
|
||||
|
||||
const text = block.text;
|
||||
|
||||
// Skip empty text
|
||||
if (!text.trim()) continue;
|
||||
|
||||
// Skip duplicate consecutive text blocks
|
||||
if (text === newLastBlock) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip final accumulated text block
|
||||
// Cursor sends one large block containing ALL previous text at the end
|
||||
if (newAccumulated.length > 100 && text.length > newAccumulated.length * 0.8) {
|
||||
const normalizedAccum = newAccumulated.replace(/\s+/g, ' ').trim();
|
||||
const normalizedNew = text.replace(/\s+/g, ' ').trim();
|
||||
if (normalizedNew.includes(normalizedAccum.slice(0, 100))) {
|
||||
// This is the final accumulated block, skip it
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// This is a valid new text block
|
||||
newLastBlock = text;
|
||||
newAccumulated += text;
|
||||
filtered.push(block);
|
||||
}
|
||||
|
||||
return {
|
||||
content: filtered,
|
||||
lastBlock: newLastBlock,
|
||||
accumulated: newAccumulated,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Cursor CLI version
|
||||
*/
|
||||
async getVersion(): Promise<string | null> {
|
||||
this.ensureCliDetected();
|
||||
if (!this.cliPath) return null;
|
||||
|
||||
try {
|
||||
if (this.useWsl && this.wslCliPath) {
|
||||
const result = execInWsl(`${this.wslCliPath} --version`, {
|
||||
timeout: 5000,
|
||||
distribution: this.wslDistribution,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
const result = execSync(`"${this.cliPath}" --version`, {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
}).trim();
|
||||
return result;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check authentication status
|
||||
*/
|
||||
async checkAuth(): Promise<CursorAuthStatus> {
|
||||
this.ensureCliDetected();
|
||||
if (!this.cliPath) {
|
||||
return { authenticated: false, method: 'none' };
|
||||
}
|
||||
|
||||
// Check for API key in environment
|
||||
if (process.env.CURSOR_API_KEY) {
|
||||
return { authenticated: true, method: 'api_key' };
|
||||
}
|
||||
|
||||
// For WSL mode, check credentials inside WSL
|
||||
if (this.useWsl && this.wslCliPath) {
|
||||
const wslOpts = { timeout: 5000, distribution: this.wslDistribution };
|
||||
|
||||
// Check for credentials file inside WSL
|
||||
const wslCredPaths = [
|
||||
'$HOME/.cursor/credentials.json',
|
||||
'$HOME/.config/cursor/credentials.json',
|
||||
];
|
||||
|
||||
for (const credPath of wslCredPaths) {
|
||||
const content = execInWsl(`sh -c "cat ${credPath} 2>/dev/null || echo ''"`, wslOpts);
|
||||
if (content && content.trim()) {
|
||||
try {
|
||||
const creds = JSON.parse(content);
|
||||
if (creds.accessToken || creds.token) {
|
||||
return { authenticated: true, method: 'login', hasCredentialsFile: true };
|
||||
}
|
||||
} catch {
|
||||
// Invalid credentials file
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try running --version to check if CLI works
|
||||
const versionResult = execInWsl(`${this.wslCliPath} --version`, {
|
||||
timeout: 10000,
|
||||
distribution: this.wslDistribution,
|
||||
});
|
||||
if (versionResult) {
|
||||
return { authenticated: true, method: 'login' };
|
||||
}
|
||||
|
||||
return { authenticated: false, method: 'none' };
|
||||
}
|
||||
|
||||
// Native mode (Linux/macOS) - check local credentials
|
||||
const credentialPaths = [
|
||||
path.join(os.homedir(), '.cursor', 'credentials.json'),
|
||||
path.join(os.homedir(), '.config', 'cursor', 'credentials.json'),
|
||||
];
|
||||
|
||||
for (const credPath of credentialPaths) {
|
||||
if (fs.existsSync(credPath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(credPath, 'utf8');
|
||||
const creds = JSON.parse(content);
|
||||
if (creds.accessToken || creds.token) {
|
||||
return { authenticated: true, method: 'login', hasCredentialsFile: true };
|
||||
}
|
||||
} catch {
|
||||
// Invalid credentials file
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try running a simple command to check auth
|
||||
try {
|
||||
execSync(`"${this.cliPath}" --version`, {
|
||||
encoding: 'utf8',
|
||||
timeout: 10000,
|
||||
env: { ...process.env },
|
||||
});
|
||||
return { authenticated: true, method: 'login' };
|
||||
} catch (error: unknown) {
|
||||
const execError = error as { stderr?: string };
|
||||
if (execError.stderr?.includes('not authenticated') || execError.stderr?.includes('log in')) {
|
||||
return { authenticated: false, method: 'none' };
|
||||
}
|
||||
}
|
||||
|
||||
return { authenticated: false, method: 'none' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect installation status (required by BaseProvider)
|
||||
*/
|
||||
async detectInstallation(): Promise<InstallationStatus> {
|
||||
const installed = await this.isInstalled();
|
||||
const version = installed ? await this.getVersion() : undefined;
|
||||
const auth = await this.checkAuth();
|
||||
|
||||
// Determine the display path - for WSL, show the WSL path with distribution
|
||||
const displayPath =
|
||||
this.useWsl && this.wslCliPath
|
||||
? `(WSL${this.wslDistribution ? `:${this.wslDistribution}` : ''}) ${this.wslCliPath}`
|
||||
: this.cliPath || undefined;
|
||||
|
||||
return {
|
||||
installed,
|
||||
version: version || undefined,
|
||||
path: displayPath,
|
||||
method: this.useWsl ? 'wsl' : 'cli',
|
||||
hasApiKey: !!process.env.CURSOR_API_KEY,
|
||||
authenticated: auth.authenticated,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the detected CLI path (public accessor for status endpoints)
|
||||
*/
|
||||
getCliPath(): string | null {
|
||||
this.ensureCliDetected();
|
||||
return this.cliPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available Cursor models
|
||||
*/
|
||||
getAvailableModels(): ModelDefinition[] {
|
||||
return Object.entries(CURSOR_MODEL_MAP).map(([id, config]) => ({
|
||||
id: `cursor-${id}`,
|
||||
name: config.label,
|
||||
modelString: id,
|
||||
provider: 'cursor',
|
||||
description: config.description,
|
||||
supportsTools: true,
|
||||
supportsVision: config.supportsVision,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a feature is supported
|
||||
*/
|
||||
supportsFeature(feature: string): boolean {
|
||||
const supported = ['tools', 'text', 'streaming'];
|
||||
return supported.includes(feature);
|
||||
}
|
||||
}
|
||||
29
apps/server/src/providers/index.ts
Normal file
29
apps/server/src/providers/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Provider exports
|
||||
*/
|
||||
|
||||
// Base providers
|
||||
export { BaseProvider } from './base-provider.js';
|
||||
export {
|
||||
CliProvider,
|
||||
type SpawnStrategy,
|
||||
type CliSpawnConfig,
|
||||
type CliErrorInfo,
|
||||
} from './cli-provider.js';
|
||||
export type {
|
||||
ProviderConfig,
|
||||
ExecuteOptions,
|
||||
ProviderMessage,
|
||||
InstallationStatus,
|
||||
ModelDefinition,
|
||||
} from './types.js';
|
||||
|
||||
// Claude provider
|
||||
export { ClaudeProvider } from './claude-provider.js';
|
||||
|
||||
// Cursor provider
|
||||
export { CursorProvider, CursorErrorCode, CursorError } from './cursor-provider.js';
|
||||
export { CursorConfigManager } from './cursor-config-manager.js';
|
||||
|
||||
// Provider factory
|
||||
export { ProviderFactory } from './provider-factory.js';
|
||||
@@ -1,51 +1,103 @@
|
||||
/**
|
||||
* Provider Factory - Routes model IDs to the appropriate provider
|
||||
*
|
||||
* This factory implements model-based routing to automatically select
|
||||
* the correct provider based on the model string. This makes adding
|
||||
* new providers (Cursor, OpenCode, etc.) trivial - just add one line.
|
||||
* Uses a registry pattern for dynamic provider registration.
|
||||
* Providers register themselves on import, making it easy to add new providers.
|
||||
*/
|
||||
|
||||
import { BaseProvider } from './base-provider.js';
|
||||
import { ClaudeProvider } from './claude-provider.js';
|
||||
import type { InstallationStatus } from './types.js';
|
||||
import type { InstallationStatus, ModelDefinition } from './types.js';
|
||||
import { isCursorModel, type ModelProvider } from '@automaker/types';
|
||||
|
||||
/**
|
||||
* Provider registration entry
|
||||
*/
|
||||
interface ProviderRegistration {
|
||||
/** Factory function to create provider instance */
|
||||
factory: () => BaseProvider;
|
||||
/** Aliases for this provider (e.g., 'anthropic' for 'claude') */
|
||||
aliases?: string[];
|
||||
/** Function to check if this provider can handle a model ID */
|
||||
canHandleModel?: (modelId: string) => boolean;
|
||||
/** Priority for model matching (higher = checked first) */
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider registry - stores registered providers
|
||||
*/
|
||||
const providerRegistry = new Map<string, ProviderRegistration>();
|
||||
|
||||
/**
|
||||
* Register a provider with the factory
|
||||
*
|
||||
* @param name Provider name (e.g., 'claude', 'cursor')
|
||||
* @param registration Provider registration config
|
||||
*/
|
||||
export function registerProvider(name: string, registration: ProviderRegistration): void {
|
||||
providerRegistry.set(name.toLowerCase(), registration);
|
||||
}
|
||||
|
||||
export class ProviderFactory {
|
||||
/**
|
||||
* Determine which provider to use for a given model
|
||||
*
|
||||
* @param model Model identifier
|
||||
* @returns Provider name (ModelProvider type)
|
||||
*/
|
||||
static getProviderNameForModel(model: string): ModelProvider {
|
||||
const lowerModel = model.toLowerCase();
|
||||
|
||||
// Get all registered providers sorted by priority (descending)
|
||||
const registrations = Array.from(providerRegistry.entries()).sort(
|
||||
([, a], [, b]) => (b.priority ?? 0) - (a.priority ?? 0)
|
||||
);
|
||||
|
||||
// Check each provider's canHandleModel function
|
||||
for (const [name, reg] of registrations) {
|
||||
if (reg.canHandleModel?.(lowerModel)) {
|
||||
return name as ModelProvider;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Check for explicit prefixes
|
||||
for (const [name] of registrations) {
|
||||
if (lowerModel.startsWith(`${name}-`)) {
|
||||
return name as ModelProvider;
|
||||
}
|
||||
}
|
||||
|
||||
// Default to claude (first registered provider or claude)
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate provider for a given model ID
|
||||
*
|
||||
* @param modelId Model identifier (e.g., "claude-opus-4-5-20251101", "gpt-5.2", "cursor-fast")
|
||||
* @param modelId Model identifier (e.g., "claude-opus-4-5-20251101", "cursor-gpt-4o", "cursor-auto")
|
||||
* @returns Provider instance for the model
|
||||
*/
|
||||
static getProviderForModel(modelId: string): BaseProvider {
|
||||
const lowerModel = modelId.toLowerCase();
|
||||
const providerName = this.getProviderNameForModel(modelId);
|
||||
const provider = this.getProviderByName(providerName);
|
||||
|
||||
// Claude models (claude-*, opus, sonnet, haiku)
|
||||
if (lowerModel.startsWith('claude-') || ['haiku', 'sonnet', 'opus'].includes(lowerModel)) {
|
||||
return new ClaudeProvider();
|
||||
if (!provider) {
|
||||
// Fallback to claude if provider not found
|
||||
const claudeReg = providerRegistry.get('claude');
|
||||
if (claudeReg) {
|
||||
return claudeReg.factory();
|
||||
}
|
||||
throw new Error(`No provider found for model: ${modelId}`);
|
||||
}
|
||||
|
||||
// Future providers:
|
||||
// if (lowerModel.startsWith("cursor-")) {
|
||||
// return new CursorProvider();
|
||||
// }
|
||||
// if (lowerModel.startsWith("opencode-")) {
|
||||
// return new OpenCodeProvider();
|
||||
// }
|
||||
|
||||
// Default to Claude for unknown models
|
||||
console.warn(`[ProviderFactory] Unknown model prefix for "${modelId}", defaulting to Claude`);
|
||||
return new ClaudeProvider();
|
||||
return provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available providers
|
||||
*/
|
||||
static getAllProviders(): BaseProvider[] {
|
||||
return [
|
||||
new ClaudeProvider(),
|
||||
// Future providers...
|
||||
];
|
||||
return Array.from(providerRegistry.values()).map((reg) => reg.factory());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,11 +106,10 @@ export class ProviderFactory {
|
||||
* @returns Map of provider name to installation status
|
||||
*/
|
||||
static async checkAllProviders(): Promise<Record<string, InstallationStatus>> {
|
||||
const providers = this.getAllProviders();
|
||||
const statuses: Record<string, InstallationStatus> = {};
|
||||
|
||||
for (const provider of providers) {
|
||||
const name = provider.getName();
|
||||
for (const [name, reg] of providerRegistry.entries()) {
|
||||
const provider = reg.factory();
|
||||
const status = await provider.detectInstallation();
|
||||
statuses[name] = status;
|
||||
}
|
||||
@@ -69,40 +120,67 @@ export class ProviderFactory {
|
||||
/**
|
||||
* Get provider by name (for direct access if needed)
|
||||
*
|
||||
* @param name Provider name (e.g., "claude", "cursor")
|
||||
* @param name Provider name (e.g., "claude", "cursor") or alias (e.g., "anthropic")
|
||||
* @returns Provider instance or null if not found
|
||||
*/
|
||||
static getProviderByName(name: string): BaseProvider | null {
|
||||
const lowerName = name.toLowerCase();
|
||||
|
||||
switch (lowerName) {
|
||||
case 'claude':
|
||||
case 'anthropic':
|
||||
return new ClaudeProvider();
|
||||
|
||||
// Future providers:
|
||||
// case "cursor":
|
||||
// return new CursorProvider();
|
||||
// case "opencode":
|
||||
// return new OpenCodeProvider();
|
||||
|
||||
default:
|
||||
return null;
|
||||
// Direct lookup
|
||||
const directReg = providerRegistry.get(lowerName);
|
||||
if (directReg) {
|
||||
return directReg.factory();
|
||||
}
|
||||
|
||||
// Check aliases
|
||||
for (const [, reg] of providerRegistry.entries()) {
|
||||
if (reg.aliases?.includes(lowerName)) {
|
||||
return reg.factory();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available models from all providers
|
||||
*/
|
||||
static getAllAvailableModels() {
|
||||
static getAllAvailableModels(): ModelDefinition[] {
|
||||
const providers = this.getAllProviders();
|
||||
const allModels = [];
|
||||
return providers.flatMap((p) => p.getAvailableModels());
|
||||
}
|
||||
|
||||
for (const provider of providers) {
|
||||
const models = provider.getAvailableModels();
|
||||
allModels.push(...models);
|
||||
}
|
||||
|
||||
return allModels;
|
||||
/**
|
||||
* Get list of registered provider names
|
||||
*/
|
||||
static getRegisteredProviderNames(): string[] {
|
||||
return Array.from(providerRegistry.keys());
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Provider Registrations
|
||||
// =============================================================================
|
||||
|
||||
// Import providers for registration side-effects
|
||||
import { ClaudeProvider } from './claude-provider.js';
|
||||
import { CursorProvider } from './cursor-provider.js';
|
||||
|
||||
// Register Claude provider
|
||||
registerProvider('claude', {
|
||||
factory: () => new ClaudeProvider(),
|
||||
aliases: ['anthropic'],
|
||||
canHandleModel: (model: string) => {
|
||||
return (
|
||||
model.startsWith('claude-') || ['opus', 'sonnet', 'haiku'].some((n) => model.includes(n))
|
||||
);
|
||||
},
|
||||
priority: 0, // Default priority
|
||||
});
|
||||
|
||||
// Register Cursor provider
|
||||
registerProvider('cursor', {
|
||||
factory: () => new CursorProvider(),
|
||||
canHandleModel: (model: string) => isCursorModel(model),
|
||||
priority: 10, // Higher priority - check Cursor models first
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* Shared types for AI model providers
|
||||
*
|
||||
* Re-exports types from @automaker/types for consistency across the codebase.
|
||||
* All provider types are defined in @automaker/types to avoid duplication.
|
||||
*/
|
||||
|
||||
// Re-export all provider types from @automaker/types
|
||||
@@ -13,72 +14,9 @@ export type {
|
||||
McpStdioServerConfig,
|
||||
McpSSEServerConfig,
|
||||
McpHttpServerConfig,
|
||||
ContentBlock,
|
||||
ProviderMessage,
|
||||
InstallationStatus,
|
||||
ValidationResult,
|
||||
ModelDefinition,
|
||||
} from '@automaker/types';
|
||||
|
||||
/**
|
||||
* Content block in a provider message (matches Claude SDK format)
|
||||
*/
|
||||
export interface ContentBlock {
|
||||
type: 'text' | 'tool_use' | 'thinking' | 'tool_result';
|
||||
text?: string;
|
||||
thinking?: string;
|
||||
name?: string;
|
||||
input?: unknown;
|
||||
tool_use_id?: string;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Message returned by a provider (matches Claude SDK streaming format)
|
||||
*/
|
||||
export interface ProviderMessage {
|
||||
type: 'assistant' | 'user' | 'error' | 'result';
|
||||
subtype?: 'success' | 'error';
|
||||
session_id?: string;
|
||||
message?: {
|
||||
role: 'user' | 'assistant';
|
||||
content: ContentBlock[];
|
||||
};
|
||||
result?: string;
|
||||
error?: string;
|
||||
parent_tool_use_id?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Installation status for a provider
|
||||
*/
|
||||
export interface InstallationStatus {
|
||||
installed: boolean;
|
||||
path?: string;
|
||||
version?: string;
|
||||
method?: 'cli' | 'npm' | 'brew' | 'sdk';
|
||||
hasApiKey?: boolean;
|
||||
authenticated?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation result
|
||||
*/
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Model definition
|
||||
*/
|
||||
export interface ModelDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
modelString: string;
|
||||
provider: string;
|
||||
description: string;
|
||||
contextWindow?: number;
|
||||
maxOutputTokens?: number;
|
||||
supportsVision?: boolean;
|
||||
supportsTools?: boolean;
|
||||
tier?: 'basic' | 'standard' | 'premium';
|
||||
default?: boolean;
|
||||
}
|
||||
|
||||
@@ -3,17 +3,19 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { ThinkingLevel } from '@automaker/types';
|
||||
import { AgentService } from '../../../services/agent-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createQueueAddHandler(agentService: AgentService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { sessionId, message, imagePaths, model } = req.body as {
|
||||
const { sessionId, message, imagePaths, model, thinkingLevel } = req.body as {
|
||||
sessionId: string;
|
||||
message: string;
|
||||
imagePaths?: string[];
|
||||
model?: string;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
};
|
||||
|
||||
if (!sessionId || !message) {
|
||||
@@ -24,7 +26,12 @@ export function createQueueAddHandler(agentService: AgentService) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await agentService.addToQueue(sessionId, { message, imagePaths, model });
|
||||
const result = await agentService.addToQueue(sessionId, {
|
||||
message,
|
||||
imagePaths,
|
||||
model,
|
||||
thinkingLevel,
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logError(error, 'Add to queue failed');
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { ThinkingLevel } from '@automaker/types';
|
||||
import { AgentService } from '../../../services/agent-service.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
@@ -11,24 +12,27 @@ const logger = createLogger('Agent');
|
||||
export function createSendHandler(agentService: AgentService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { sessionId, message, workingDirectory, imagePaths, model } = req.body as {
|
||||
sessionId: string;
|
||||
message: string;
|
||||
workingDirectory?: string;
|
||||
imagePaths?: string[];
|
||||
model?: string;
|
||||
};
|
||||
const { sessionId, message, workingDirectory, imagePaths, model, thinkingLevel } =
|
||||
req.body as {
|
||||
sessionId: string;
|
||||
message: string;
|
||||
workingDirectory?: string;
|
||||
imagePaths?: string[];
|
||||
model?: string;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
};
|
||||
|
||||
console.log('[Send Handler] Received request:', {
|
||||
logger.debug('Received request:', {
|
||||
sessionId,
|
||||
messageLength: message?.length,
|
||||
workingDirectory,
|
||||
imageCount: imagePaths?.length || 0,
|
||||
model,
|
||||
thinkingLevel,
|
||||
});
|
||||
|
||||
if (!sessionId || !message) {
|
||||
console.log('[Send Handler] ERROR: Validation failed - missing sessionId or message');
|
||||
logger.warn('Validation failed - missing sessionId or message');
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'sessionId and message are required',
|
||||
@@ -36,7 +40,7 @@ export function createSendHandler(agentService: AgentService) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Send Handler] Validation passed, calling agentService.sendMessage()');
|
||||
logger.debug('Validation passed, calling agentService.sendMessage()');
|
||||
|
||||
// Start the message processing (don't await - it streams via WebSocket)
|
||||
agentService
|
||||
@@ -46,18 +50,19 @@ export function createSendHandler(agentService: AgentService) {
|
||||
workingDirectory,
|
||||
imagePaths,
|
||||
model,
|
||||
thinkingLevel,
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[Send Handler] ERROR: Background error in sendMessage():', error);
|
||||
logger.error('Background error in sendMessage():', error);
|
||||
logError(error, 'Send message failed (background)');
|
||||
});
|
||||
|
||||
console.log('[Send Handler] Returning immediate response to client');
|
||||
logger.debug('Returning immediate response to client');
|
||||
|
||||
// Return immediately - responses come via WebSocket
|
||||
res.json({ success: true, message: 'Message sent' });
|
||||
} catch (error) {
|
||||
console.error('[Send Handler] ERROR: Synchronous error:', error);
|
||||
logger.error('Synchronous error:', error);
|
||||
logError(error, 'Send message failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
/**
|
||||
* Generate features from existing app_spec.txt
|
||||
*
|
||||
* Model is configurable via phaseModels.featureGenerationModel in settings
|
||||
* (defaults to Sonnet for balanced speed and quality).
|
||||
*/
|
||||
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import * as secureFs from '../../lib/secure-fs.js';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { createFeatureGenerationOptions } from '../../lib/sdk-options.js';
|
||||
import { ProviderFactory } from '../../providers/provider-factory.js';
|
||||
import { logAuthStatus } from './common.js';
|
||||
import { parseAndCreateFeatures } from './parse-and-create-features.js';
|
||||
import { getAppSpecPath } from '@automaker/platform';
|
||||
@@ -101,43 +107,46 @@ IMPORTANT: Do not ask for clarification. The specification is provided above. Ge
|
||||
'[FeatureGeneration]'
|
||||
);
|
||||
|
||||
const options = createFeatureGenerationOptions({
|
||||
cwd: projectPath,
|
||||
abortController,
|
||||
autoLoadClaudeMd,
|
||||
});
|
||||
// Get model from phase settings
|
||||
const settings = await settingsService?.getGlobalSettings();
|
||||
const phaseModelEntry =
|
||||
settings?.phaseModels?.featureGenerationModel || DEFAULT_PHASE_MODELS.featureGenerationModel;
|
||||
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
|
||||
|
||||
logger.debug('SDK Options:', JSON.stringify(options, null, 2));
|
||||
logger.info('Calling Claude Agent SDK query() for features...');
|
||||
|
||||
logAuthStatus('Right before SDK query() for features');
|
||||
|
||||
let stream;
|
||||
try {
|
||||
stream = query({ prompt, options });
|
||||
logger.debug('query() returned stream successfully');
|
||||
} catch (queryError) {
|
||||
logger.error('❌ query() threw an exception:');
|
||||
logger.error('Error:', queryError);
|
||||
throw queryError;
|
||||
}
|
||||
logger.info('Using model:', model);
|
||||
|
||||
let responseText = '';
|
||||
let messageCount = 0;
|
||||
|
||||
logger.debug('Starting to iterate over feature stream...');
|
||||
// Route to appropriate provider based on model type
|
||||
if (isCursorModel(model)) {
|
||||
// Use Cursor provider for Cursor models
|
||||
logger.info('[FeatureGeneration] Using Cursor provider');
|
||||
|
||||
try {
|
||||
for await (const msg of stream) {
|
||||
const provider = ProviderFactory.getProviderForModel(model);
|
||||
|
||||
// Add explicit instructions for Cursor to return JSON in response
|
||||
const cursorPrompt = `${prompt}
|
||||
|
||||
CRITICAL INSTRUCTIONS:
|
||||
1. DO NOT write any files. Return the JSON in your response only.
|
||||
2. Respond with ONLY a JSON object - no explanations, no markdown, just raw JSON.
|
||||
3. Your entire response should be valid JSON starting with { and ending with }. No text before or after.`;
|
||||
|
||||
for await (const msg of provider.executeQuery({
|
||||
prompt: cursorPrompt,
|
||||
model,
|
||||
cwd: projectPath,
|
||||
maxTurns: 250,
|
||||
allowedTools: ['Read', 'Glob', 'Grep'],
|
||||
abortController,
|
||||
readOnly: true, // Feature generation only reads code, doesn't write
|
||||
})) {
|
||||
messageCount++;
|
||||
logger.debug(
|
||||
`Feature stream message #${messageCount}:`,
|
||||
JSON.stringify({ type: msg.type, subtype: (msg as any).subtype }, null, 2)
|
||||
);
|
||||
|
||||
if (msg.type === 'assistant' && msg.message.content) {
|
||||
if (msg.type === 'assistant' && msg.message?.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === 'text') {
|
||||
if (block.type === 'text' && block.text) {
|
||||
responseText += block.text;
|
||||
logger.debug(`Feature text block received (${block.text.length} chars)`);
|
||||
events.emit('spec-regeneration:event', {
|
||||
@@ -147,18 +156,75 @@ IMPORTANT: Do not ask for clarification. The specification is provided above. Ge
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'result' && (msg as any).subtype === 'success') {
|
||||
logger.debug('Received success result for features');
|
||||
responseText = (msg as any).result || responseText;
|
||||
} else if ((msg as { type: string }).type === 'error') {
|
||||
logger.error('❌ Received error message from feature stream:');
|
||||
logger.error('Error message:', JSON.stringify(msg, null, 2));
|
||||
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
|
||||
// Use result if it's a final accumulated message
|
||||
if (msg.result.length > responseText.length) {
|
||||
responseText = msg.result;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (streamError) {
|
||||
logger.error('❌ Error while iterating feature stream:');
|
||||
logger.error('Stream error:', streamError);
|
||||
throw streamError;
|
||||
} else {
|
||||
// Use Claude SDK for Claude models
|
||||
logger.info('[FeatureGeneration] Using Claude SDK');
|
||||
|
||||
const options = createFeatureGenerationOptions({
|
||||
cwd: projectPath,
|
||||
abortController,
|
||||
autoLoadClaudeMd,
|
||||
model,
|
||||
thinkingLevel, // Pass thinking level for extended thinking
|
||||
});
|
||||
|
||||
logger.debug('SDK Options:', JSON.stringify(options, null, 2));
|
||||
logger.info('Calling Claude Agent SDK query() for features...');
|
||||
|
||||
logAuthStatus('Right before SDK query() for features');
|
||||
|
||||
let stream;
|
||||
try {
|
||||
stream = query({ prompt, options });
|
||||
logger.debug('query() returned stream successfully');
|
||||
} catch (queryError) {
|
||||
logger.error('❌ query() threw an exception:');
|
||||
logger.error('Error:', queryError);
|
||||
throw queryError;
|
||||
}
|
||||
|
||||
logger.debug('Starting to iterate over feature stream...');
|
||||
|
||||
try {
|
||||
for await (const msg of stream) {
|
||||
messageCount++;
|
||||
logger.debug(
|
||||
`Feature stream message #${messageCount}:`,
|
||||
JSON.stringify({ type: msg.type, subtype: (msg as any).subtype }, null, 2)
|
||||
);
|
||||
|
||||
if (msg.type === 'assistant' && msg.message.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === 'text') {
|
||||
responseText += block.text;
|
||||
logger.debug(`Feature text block received (${block.text.length} chars)`);
|
||||
events.emit('spec-regeneration:event', {
|
||||
type: 'spec_regeneration_progress',
|
||||
content: block.text,
|
||||
projectPath: projectPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'result' && (msg as any).subtype === 'success') {
|
||||
logger.debug('Received success result for features');
|
||||
responseText = (msg as any).result || responseText;
|
||||
} else if ((msg as { type: string }).type === 'error') {
|
||||
logger.error('❌ Received error message from feature stream:');
|
||||
logger.error('Error message:', JSON.stringify(msg, null, 2));
|
||||
}
|
||||
}
|
||||
} catch (streamError) {
|
||||
logger.error('❌ Error while iterating feature stream:');
|
||||
logger.error('Stream error:', streamError);
|
||||
throw streamError;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Feature stream complete. Total messages: ${messageCount}`);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
/**
|
||||
* Generate app_spec.txt from project overview
|
||||
*
|
||||
* Model is configurable via phaseModels.specGenerationModel in settings
|
||||
* (defaults to Opus for high-quality specification generation).
|
||||
*/
|
||||
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
@@ -13,7 +16,11 @@ import {
|
||||
type SpecOutput,
|
||||
} from '../../lib/app-spec-format.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { createSpecGenerationOptions } from '../../lib/sdk-options.js';
|
||||
import { extractJson } from '../../lib/json-extractor.js';
|
||||
import { ProviderFactory } from '../../providers/provider-factory.js';
|
||||
import { logAuthStatus } from './common.js';
|
||||
import { generateFeaturesFromSpec } from './generate-features-from-spec.js';
|
||||
import { ensureAutomakerDir, getAppSpecPath } from '@automaker/platform';
|
||||
@@ -93,102 +100,181 @@ ${getStructuredSpecPromptInstruction()}`;
|
||||
'[SpecRegeneration]'
|
||||
);
|
||||
|
||||
const options = createSpecGenerationOptions({
|
||||
cwd: projectPath,
|
||||
abortController,
|
||||
autoLoadClaudeMd,
|
||||
outputFormat: {
|
||||
type: 'json_schema',
|
||||
schema: specOutputSchema,
|
||||
},
|
||||
});
|
||||
// Get model from phase settings
|
||||
const settings = await settingsService?.getGlobalSettings();
|
||||
const phaseModelEntry =
|
||||
settings?.phaseModels?.specGenerationModel || DEFAULT_PHASE_MODELS.specGenerationModel;
|
||||
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
|
||||
|
||||
logger.debug('SDK Options:', JSON.stringify(options, null, 2));
|
||||
logger.info('Calling Claude Agent SDK query()...');
|
||||
|
||||
// Log auth status right before the SDK call
|
||||
logAuthStatus('Right before SDK query()');
|
||||
|
||||
let stream;
|
||||
try {
|
||||
stream = query({ prompt, options });
|
||||
logger.debug('query() returned stream successfully');
|
||||
} catch (queryError) {
|
||||
logger.error('❌ query() threw an exception:');
|
||||
logger.error('Error:', queryError);
|
||||
throw queryError;
|
||||
}
|
||||
logger.info('Using model:', model);
|
||||
|
||||
let responseText = '';
|
||||
let messageCount = 0;
|
||||
let structuredOutput: SpecOutput | null = null;
|
||||
|
||||
logger.info('Starting to iterate over stream...');
|
||||
// Route to appropriate provider based on model type
|
||||
if (isCursorModel(model)) {
|
||||
// Use Cursor provider for Cursor models
|
||||
logger.info('[SpecGeneration] Using Cursor provider');
|
||||
|
||||
try {
|
||||
for await (const msg of stream) {
|
||||
const provider = ProviderFactory.getProviderForModel(model);
|
||||
|
||||
// For Cursor, include the JSON schema in the prompt with clear instructions
|
||||
// to return JSON in the response (not write to a file)
|
||||
const cursorPrompt = `${prompt}
|
||||
|
||||
CRITICAL INSTRUCTIONS:
|
||||
1. DO NOT write any files. DO NOT create any files like "project_specification.json".
|
||||
2. After analyzing the project, respond with ONLY a JSON object - no explanations, no markdown, just raw JSON.
|
||||
3. The JSON must match this exact schema:
|
||||
|
||||
${JSON.stringify(specOutputSchema, null, 2)}
|
||||
|
||||
Your entire response should be valid JSON starting with { and ending with }. No text before or after.`;
|
||||
|
||||
for await (const msg of provider.executeQuery({
|
||||
prompt: cursorPrompt,
|
||||
model,
|
||||
cwd: projectPath,
|
||||
maxTurns: 250,
|
||||
allowedTools: ['Read', 'Glob', 'Grep'],
|
||||
abortController,
|
||||
readOnly: true, // Spec generation only reads code, we write the spec ourselves
|
||||
})) {
|
||||
messageCount++;
|
||||
logger.info(
|
||||
`Stream message #${messageCount}: type=${msg.type}, subtype=${(msg as any).subtype}`
|
||||
);
|
||||
|
||||
if (msg.type === 'assistant') {
|
||||
const msgAny = msg as any;
|
||||
if (msgAny.message?.content) {
|
||||
for (const block of msgAny.message.content) {
|
||||
if (block.type === 'text') {
|
||||
responseText += block.text;
|
||||
logger.info(
|
||||
`Text block received (${block.text.length} chars), total now: ${responseText.length} chars`
|
||||
);
|
||||
events.emit('spec-regeneration:event', {
|
||||
type: 'spec_regeneration_progress',
|
||||
content: block.text,
|
||||
projectPath: projectPath,
|
||||
});
|
||||
} else if (block.type === 'tool_use') {
|
||||
logger.info('Tool use:', block.name);
|
||||
events.emit('spec-regeneration:event', {
|
||||
type: 'spec_tool',
|
||||
tool: block.name,
|
||||
input: block.input,
|
||||
});
|
||||
}
|
||||
if (msg.type === 'assistant' && msg.message?.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
responseText += block.text;
|
||||
logger.info(
|
||||
`Text block received (${block.text.length} chars), total now: ${responseText.length} chars`
|
||||
);
|
||||
events.emit('spec-regeneration:event', {
|
||||
type: 'spec_regeneration_progress',
|
||||
content: block.text,
|
||||
projectPath: projectPath,
|
||||
});
|
||||
} else if (block.type === 'tool_use') {
|
||||
logger.info('Tool use:', block.name);
|
||||
events.emit('spec-regeneration:event', {
|
||||
type: 'spec_tool',
|
||||
tool: block.name,
|
||||
input: block.input,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'result' && (msg as any).subtype === 'success') {
|
||||
logger.info('Received success result');
|
||||
// Check for structured output - this is the reliable way to get spec data
|
||||
const resultMsg = msg as any;
|
||||
if (resultMsg.structured_output) {
|
||||
structuredOutput = resultMsg.structured_output as SpecOutput;
|
||||
logger.info('✅ Received structured output');
|
||||
logger.debug('Structured output:', JSON.stringify(structuredOutput, null, 2));
|
||||
} else {
|
||||
logger.warn('⚠️ No structured output in result, will fall back to text parsing');
|
||||
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
|
||||
// Use result if it's a final accumulated message
|
||||
if (msg.result.length > responseText.length) {
|
||||
responseText = msg.result;
|
||||
}
|
||||
} else if (msg.type === 'result') {
|
||||
// Handle error result types
|
||||
const subtype = (msg as any).subtype;
|
||||
logger.info(`Result message: subtype=${subtype}`);
|
||||
if (subtype === 'error_max_turns') {
|
||||
logger.error('❌ Hit max turns limit!');
|
||||
} else if (subtype === 'error_max_structured_output_retries') {
|
||||
logger.error('❌ Failed to produce valid structured output after retries');
|
||||
throw new Error('Could not produce valid spec output');
|
||||
}
|
||||
} else if ((msg as { type: string }).type === 'error') {
|
||||
logger.error('❌ Received error message from stream:');
|
||||
logger.error('Error message:', JSON.stringify(msg, null, 2));
|
||||
} else if (msg.type === 'user') {
|
||||
// Log user messages (tool results)
|
||||
logger.info(`User message (tool result): ${JSON.stringify(msg).substring(0, 500)}`);
|
||||
}
|
||||
}
|
||||
} catch (streamError) {
|
||||
logger.error('❌ Error while iterating stream:');
|
||||
logger.error('Stream error:', streamError);
|
||||
throw streamError;
|
||||
|
||||
// Parse JSON from the response text using shared utility
|
||||
if (responseText) {
|
||||
structuredOutput = extractJson<SpecOutput>(responseText, { logger });
|
||||
}
|
||||
} else {
|
||||
// Use Claude SDK for Claude models
|
||||
logger.info('[SpecGeneration] Using Claude SDK');
|
||||
|
||||
const options = createSpecGenerationOptions({
|
||||
cwd: projectPath,
|
||||
abortController,
|
||||
autoLoadClaudeMd,
|
||||
model,
|
||||
thinkingLevel, // Pass thinking level for extended thinking
|
||||
outputFormat: {
|
||||
type: 'json_schema',
|
||||
schema: specOutputSchema,
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug('SDK Options:', JSON.stringify(options, null, 2));
|
||||
logger.info('Calling Claude Agent SDK query()...');
|
||||
|
||||
// Log auth status right before the SDK call
|
||||
logAuthStatus('Right before SDK query()');
|
||||
|
||||
let stream;
|
||||
try {
|
||||
stream = query({ prompt, options });
|
||||
logger.debug('query() returned stream successfully');
|
||||
} catch (queryError) {
|
||||
logger.error('❌ query() threw an exception:');
|
||||
logger.error('Error:', queryError);
|
||||
throw queryError;
|
||||
}
|
||||
|
||||
logger.info('Starting to iterate over stream...');
|
||||
|
||||
try {
|
||||
for await (const msg of stream) {
|
||||
messageCount++;
|
||||
logger.info(
|
||||
`Stream message #${messageCount}: type=${msg.type}, subtype=${(msg as any).subtype}`
|
||||
);
|
||||
|
||||
if (msg.type === 'assistant') {
|
||||
const msgAny = msg as any;
|
||||
if (msgAny.message?.content) {
|
||||
for (const block of msgAny.message.content) {
|
||||
if (block.type === 'text') {
|
||||
responseText += block.text;
|
||||
logger.info(
|
||||
`Text block received (${block.text.length} chars), total now: ${responseText.length} chars`
|
||||
);
|
||||
events.emit('spec-regeneration:event', {
|
||||
type: 'spec_regeneration_progress',
|
||||
content: block.text,
|
||||
projectPath: projectPath,
|
||||
});
|
||||
} else if (block.type === 'tool_use') {
|
||||
logger.info('Tool use:', block.name);
|
||||
events.emit('spec-regeneration:event', {
|
||||
type: 'spec_tool',
|
||||
tool: block.name,
|
||||
input: block.input,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'result' && (msg as any).subtype === 'success') {
|
||||
logger.info('Received success result');
|
||||
// Check for structured output - this is the reliable way to get spec data
|
||||
const resultMsg = msg as any;
|
||||
if (resultMsg.structured_output) {
|
||||
structuredOutput = resultMsg.structured_output as SpecOutput;
|
||||
logger.info('✅ Received structured output');
|
||||
logger.debug('Structured output:', JSON.stringify(structuredOutput, null, 2));
|
||||
} else {
|
||||
logger.warn('⚠️ No structured output in result, will fall back to text parsing');
|
||||
}
|
||||
} else if (msg.type === 'result') {
|
||||
// Handle error result types
|
||||
const subtype = (msg as any).subtype;
|
||||
logger.info(`Result message: subtype=${subtype}`);
|
||||
if (subtype === 'error_max_turns') {
|
||||
logger.error('❌ Hit max turns limit!');
|
||||
} else if (subtype === 'error_max_structured_output_retries') {
|
||||
logger.error('❌ Failed to produce valid structured output after retries');
|
||||
throw new Error('Could not produce valid spec output');
|
||||
}
|
||||
} else if ((msg as { type: string }).type === 'error') {
|
||||
logger.error('❌ Received error message from stream:');
|
||||
logger.error('Error message:', JSON.stringify(msg, null, 2));
|
||||
} else if (msg.type === 'user') {
|
||||
// Log user messages (tool results)
|
||||
logger.info(`User message (tool result): ${JSON.stringify(msg).substring(0, 500)}`);
|
||||
}
|
||||
}
|
||||
} catch (streamError) {
|
||||
logger.error('❌ Error while iterating stream:');
|
||||
logger.error('Stream error:', streamError);
|
||||
throw streamError;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Stream iteration complete. Total messages: ${messageCount}`);
|
||||
|
||||
@@ -7,6 +7,7 @@ import * as secureFs from '../../lib/secure-fs.js';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getFeaturesDir } from '@automaker/platform';
|
||||
import { extractJsonWithArray } from '../../lib/json-extractor.js';
|
||||
|
||||
const logger = createLogger('SpecRegeneration');
|
||||
|
||||
@@ -22,23 +23,30 @@ export async function parseAndCreateFeatures(
|
||||
logger.info('========== END CONTENT ==========');
|
||||
|
||||
try {
|
||||
// Extract JSON from response
|
||||
logger.info('Extracting JSON from response...');
|
||||
logger.info(`Looking for pattern: /{[\\s\\S]*"features"[\\s\\S]*}/`);
|
||||
const jsonMatch = content.match(/\{[\s\S]*"features"[\s\S]*\}/);
|
||||
if (!jsonMatch) {
|
||||
logger.error('❌ No valid JSON found in response');
|
||||
// Extract JSON from response using shared utility
|
||||
logger.info('Extracting JSON from response using extractJsonWithArray...');
|
||||
|
||||
interface FeaturesResponse {
|
||||
features: Array<{
|
||||
id: string;
|
||||
category?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
priority?: number;
|
||||
complexity?: string;
|
||||
dependencies?: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
const parsed = extractJsonWithArray<FeaturesResponse>(content, 'features', { logger });
|
||||
|
||||
if (!parsed || !parsed.features) {
|
||||
logger.error('❌ No valid JSON with "features" array found in response');
|
||||
logger.error('Full content received:');
|
||||
logger.error(content);
|
||||
throw new Error('No valid JSON found in response');
|
||||
}
|
||||
|
||||
logger.info(`JSON match found (${jsonMatch[0].length} chars)`);
|
||||
logger.info('========== MATCHED JSON ==========');
|
||||
logger.info(jsonMatch[0]);
|
||||
logger.info('========== END MATCHED JSON ==========');
|
||||
|
||||
const parsed = JSON.parse(jsonMatch[0]);
|
||||
logger.info(`Parsed ${parsed.features?.length || 0} features`);
|
||||
logger.info('Parsed features:', JSON.stringify(parsed.features, null, 2));
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ export function createResumeFeatureHandler(autoModeService: AutoModeService) {
|
||||
autoModeService
|
||||
.resumeFeature(projectPath, featureId, useWorktrees ?? false)
|
||||
.catch((error) => {
|
||||
logger.error(`[AutoMode] Resume feature ${featureId} error:`, error);
|
||||
logger.error(`Resume feature ${featureId} error:`, error);
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
|
||||
@@ -31,7 +31,7 @@ export function createRunFeatureHandler(autoModeService: AutoModeService) {
|
||||
autoModeService
|
||||
.executeFeature(projectPath, featureId, useWorktrees ?? false, false)
|
||||
.catch((error) => {
|
||||
logger.error(`[AutoMode] Feature ${featureId} error:`, error);
|
||||
logger.error(`Feature ${featureId} error:`, error);
|
||||
})
|
||||
.finally(() => {
|
||||
// Release the starting slot when execution completes (success or error)
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
/**
|
||||
* Generate backlog plan using Claude AI
|
||||
*
|
||||
* Model is configurable via phaseModels.backlogPlanningModel in settings
|
||||
* (defaults to Sonnet). Can be overridden per-call via model parameter.
|
||||
*/
|
||||
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import type { Feature, BacklogPlanResult, BacklogChange, DependencyUpdate } from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS, isCursorModel, type ThinkingLevel } from '@automaker/types';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { FeatureLoader } from '../../services/feature-loader.js';
|
||||
import { ProviderFactory } from '../../providers/provider-factory.js';
|
||||
import { extractJsonWithArray } from '../../lib/json-extractor.js';
|
||||
import { logger, setRunningState, getErrorMessage } from './common.js';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js';
|
||||
@@ -39,24 +45,28 @@ function formatFeaturesForPrompt(features: Feature[]): string {
|
||||
* Parse the AI response into a BacklogPlanResult
|
||||
*/
|
||||
function parsePlanResponse(response: string): BacklogPlanResult {
|
||||
try {
|
||||
// Try to extract JSON from the response
|
||||
const jsonMatch = response.match(/```json\n?([\s\S]*?)\n?```/);
|
||||
if (jsonMatch) {
|
||||
return JSON.parse(jsonMatch[1]);
|
||||
}
|
||||
// Use shared JSON extraction utility for robust parsing
|
||||
// extractJsonWithArray validates that 'changes' exists AND is an array
|
||||
const parsed = extractJsonWithArray<BacklogPlanResult>(response, 'changes', {
|
||||
logger,
|
||||
});
|
||||
|
||||
// Try to parse the whole response as JSON
|
||||
return JSON.parse(response);
|
||||
} catch {
|
||||
// If parsing fails, return an empty result
|
||||
logger.warn('[BacklogPlan] Failed to parse AI response as JSON');
|
||||
return {
|
||||
changes: [],
|
||||
summary: 'Failed to parse AI response',
|
||||
dependencyUpdates: [],
|
||||
};
|
||||
if (parsed) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
// If parsing fails, log details and return an empty result
|
||||
logger.warn('[BacklogPlan] Failed to parse AI response as JSON');
|
||||
logger.warn('[BacklogPlan] Response text length:', response.length);
|
||||
logger.warn('[BacklogPlan] Response preview:', response.slice(0, 500));
|
||||
if (response.length === 0) {
|
||||
logger.error('[BacklogPlan] Response text is EMPTY! No content was extracted from stream.');
|
||||
}
|
||||
return {
|
||||
changes: [],
|
||||
summary: 'Failed to parse AI response',
|
||||
dependencyUpdates: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,8 +106,19 @@ export async function generateBacklogPlan(
|
||||
content: 'Generating plan with AI...',
|
||||
});
|
||||
|
||||
// Get the model to use
|
||||
const effectiveModel = model || 'sonnet';
|
||||
// Get the model to use from settings or provided override
|
||||
let effectiveModel = model;
|
||||
let thinkingLevel: ThinkingLevel | undefined;
|
||||
if (!effectiveModel) {
|
||||
const settings = await settingsService?.getGlobalSettings();
|
||||
const phaseModelEntry =
|
||||
settings?.phaseModels?.backlogPlanningModel || DEFAULT_PHASE_MODELS.backlogPlanningModel;
|
||||
const resolved = resolvePhaseModel(phaseModelEntry);
|
||||
effectiveModel = resolved.model;
|
||||
thinkingLevel = resolved.thinkingLevel;
|
||||
}
|
||||
logger.info('[BacklogPlan] Using model:', effectiveModel);
|
||||
|
||||
const provider = ProviderFactory.getProviderForModel(effectiveModel);
|
||||
|
||||
// Get autoLoadClaudeMd setting
|
||||
@@ -107,16 +128,38 @@ export async function generateBacklogPlan(
|
||||
'[BacklogPlan]'
|
||||
);
|
||||
|
||||
// For Cursor models, we need to combine prompts with explicit instructions
|
||||
// because Cursor doesn't support systemPrompt separation like Claude SDK
|
||||
let finalPrompt = userPrompt;
|
||||
let finalSystemPrompt: string | undefined = systemPrompt;
|
||||
|
||||
if (isCursorModel(effectiveModel)) {
|
||||
logger.info('[BacklogPlan] Using Cursor model - adding explicit no-file-write instructions');
|
||||
finalPrompt = `${systemPrompt}
|
||||
|
||||
CRITICAL INSTRUCTIONS:
|
||||
1. DO NOT write any files. Return the JSON in your response only.
|
||||
2. DO NOT use Write, Edit, or any file modification tools.
|
||||
3. Respond with ONLY a JSON object - no explanations, no markdown, just raw JSON.
|
||||
4. Your entire response should be valid JSON starting with { and ending with }.
|
||||
5. No text before or after the JSON object.
|
||||
|
||||
${userPrompt}`;
|
||||
finalSystemPrompt = undefined; // System prompt is now embedded in the user prompt
|
||||
}
|
||||
|
||||
// Execute the query
|
||||
const stream = provider.executeQuery({
|
||||
prompt: userPrompt,
|
||||
prompt: finalPrompt,
|
||||
model: effectiveModel,
|
||||
cwd: projectPath,
|
||||
systemPrompt,
|
||||
systemPrompt: finalSystemPrompt,
|
||||
maxTurns: 1,
|
||||
allowedTools: [], // No tools needed for this
|
||||
abortController,
|
||||
settingSources: autoLoadClaudeMd ? ['user', 'project'] : undefined,
|
||||
readOnly: true, // Plan generation only generates text, doesn't write files
|
||||
thinkingLevel, // Pass thinking level for extended thinking
|
||||
});
|
||||
|
||||
let responseText = '';
|
||||
@@ -134,6 +177,16 @@ export async function generateBacklogPlan(
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
|
||||
// Use result if it's a final accumulated message (from Cursor provider)
|
||||
logger.info('[BacklogPlan] Received result from Cursor, length:', msg.result.length);
|
||||
logger.info('[BacklogPlan] Previous responseText length:', responseText.length);
|
||||
if (msg.result.length > responseText.length) {
|
||||
logger.info('[BacklogPlan] Using Cursor result (longer than accumulated text)');
|
||||
responseText = msg.result;
|
||||
} else {
|
||||
logger.info('[BacklogPlan] Keeping accumulated text (longer than Cursor result)');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { ClaudeUsageService } from '../../services/claude-usage-service.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('Claude');
|
||||
|
||||
export function createClaudeRoutes(service: ClaudeUsageService): Router {
|
||||
const router = Router();
|
||||
@@ -33,7 +36,7 @@ export function createClaudeRoutes(service: ClaudeUsageService): Router {
|
||||
message: 'The Claude CLI took too long to respond',
|
||||
});
|
||||
} else {
|
||||
console.error('Error fetching usage:', error);
|
||||
logger.error('Error fetching usage:', error);
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* POST /context/describe-file endpoint - Generate description for a text file
|
||||
*
|
||||
* Uses Claude Haiku to analyze a text file and generate a concise description
|
||||
* suitable for context file metadata.
|
||||
* Uses AI to analyze a text file and generate a concise description
|
||||
* suitable for context file metadata. Model is configurable via
|
||||
* phaseModels.fileDescriptionModel in settings (defaults to Haiku).
|
||||
*
|
||||
* SECURITY: This endpoint validates file paths against ALLOWED_ROOT_DIRECTORY
|
||||
* and reads file content directly (not via Claude's Read tool) to prevent
|
||||
@@ -12,9 +13,11 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { CLAUDE_MODEL_MAP } from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types';
|
||||
import { PathNotAllowedError } from '@automaker/platform';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { createCustomOptions } from '../../../lib/sdk-options.js';
|
||||
import { ProviderFactory } from '../../../providers/provider-factory.js';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import * as path from 'path';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
@@ -94,7 +97,7 @@ export function createDescribeFileHandler(
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`[DescribeFile] Starting description generation for: ${filePath}`);
|
||||
logger.info(`Starting description generation for: ${filePath}`);
|
||||
|
||||
// Resolve the path for logging and cwd derivation
|
||||
const resolvedPath = secureFs.resolvePath(filePath);
|
||||
@@ -109,7 +112,7 @@ export function createDescribeFileHandler(
|
||||
} catch (readError) {
|
||||
// Path not allowed - return 403 Forbidden
|
||||
if (readError instanceof PathNotAllowedError) {
|
||||
logger.warn(`[DescribeFile] Path not allowed: ${filePath}`);
|
||||
logger.warn(`Path not allowed: ${filePath}`);
|
||||
const response: DescribeFileErrorResponse = {
|
||||
success: false,
|
||||
error: 'File path is not within the allowed directory',
|
||||
@@ -125,7 +128,7 @@ export function createDescribeFileHandler(
|
||||
'code' in readError &&
|
||||
readError.code === 'ENOENT'
|
||||
) {
|
||||
logger.warn(`[DescribeFile] File not found: ${resolvedPath}`);
|
||||
logger.warn(`File not found: ${resolvedPath}`);
|
||||
const response: DescribeFileErrorResponse = {
|
||||
success: false,
|
||||
error: `File not found: ${filePath}`,
|
||||
@@ -135,7 +138,7 @@ export function createDescribeFileHandler(
|
||||
}
|
||||
|
||||
const errorMessage = readError instanceof Error ? readError.message : 'Unknown error';
|
||||
logger.error(`[DescribeFile] Failed to read file: ${errorMessage}`);
|
||||
logger.error(`Failed to read file: ${errorMessage}`);
|
||||
const response: DescribeFileErrorResponse = {
|
||||
success: false,
|
||||
error: `Failed to read file: ${errorMessage}`,
|
||||
@@ -177,30 +180,76 @@ File: ${fileName}${truncated ? ' (truncated)' : ''}`;
|
||||
'[DescribeFile]'
|
||||
);
|
||||
|
||||
// Use centralized SDK options with proper cwd validation
|
||||
// No tools needed since we're passing file content directly
|
||||
const sdkOptions = createCustomOptions({
|
||||
cwd,
|
||||
model: CLAUDE_MODEL_MAP.haiku,
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
autoLoadClaudeMd,
|
||||
sandbox: { enabled: true, autoAllowBashIfSandboxed: true },
|
||||
});
|
||||
// Get model from phase settings
|
||||
const settings = await settingsService?.getGlobalSettings();
|
||||
logger.info(`Raw phaseModels from settings:`, JSON.stringify(settings?.phaseModels, null, 2));
|
||||
const phaseModelEntry =
|
||||
settings?.phaseModels?.fileDescriptionModel || DEFAULT_PHASE_MODELS.fileDescriptionModel;
|
||||
logger.info(`fileDescriptionModel entry:`, JSON.stringify(phaseModelEntry));
|
||||
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
|
||||
|
||||
const promptGenerator = (async function* () {
|
||||
yield {
|
||||
type: 'user' as const,
|
||||
session_id: '',
|
||||
message: { role: 'user' as const, content: promptContent },
|
||||
parent_tool_use_id: null,
|
||||
};
|
||||
})();
|
||||
logger.info(`Resolved model: ${model}, thinkingLevel: ${thinkingLevel}`);
|
||||
|
||||
const stream = query({ prompt: promptGenerator, options: sdkOptions });
|
||||
let description: string;
|
||||
|
||||
// Extract the description from the response
|
||||
const description = await extractTextFromStream(stream);
|
||||
// Route to appropriate provider based on model type
|
||||
if (isCursorModel(model)) {
|
||||
// Use Cursor provider for Cursor models
|
||||
logger.info(`Using Cursor provider for model: ${model}`);
|
||||
|
||||
const provider = ProviderFactory.getProviderForModel(model);
|
||||
|
||||
// Build a simple text prompt for Cursor (no multi-part content blocks)
|
||||
const cursorPrompt = `${instructionText}\n\n--- FILE CONTENT ---\n${contentToAnalyze}`;
|
||||
|
||||
let responseText = '';
|
||||
for await (const msg of provider.executeQuery({
|
||||
prompt: cursorPrompt,
|
||||
model,
|
||||
cwd,
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
readOnly: true, // File description only reads, doesn't write
|
||||
})) {
|
||||
if (msg.type === 'assistant' && msg.message?.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
responseText += block.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
description = responseText;
|
||||
} else {
|
||||
// Use Claude SDK for Claude models
|
||||
logger.info(`Using Claude SDK for model: ${model}`);
|
||||
|
||||
// Use centralized SDK options with proper cwd validation
|
||||
// No tools needed since we're passing file content directly
|
||||
const sdkOptions = createCustomOptions({
|
||||
cwd,
|
||||
model,
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
autoLoadClaudeMd,
|
||||
sandbox: { enabled: true, autoAllowBashIfSandboxed: true },
|
||||
thinkingLevel, // Pass thinking level for extended thinking
|
||||
});
|
||||
|
||||
const promptGenerator = (async function* () {
|
||||
yield {
|
||||
type: 'user' as const,
|
||||
session_id: '',
|
||||
message: { role: 'user' as const, content: promptContent },
|
||||
parent_tool_use_id: null,
|
||||
};
|
||||
})();
|
||||
|
||||
const stream = query({ prompt: promptGenerator, options: sdkOptions });
|
||||
|
||||
// Extract the description from the response
|
||||
description = await extractTextFromStream(stream);
|
||||
}
|
||||
|
||||
if (!description || description.trim().length === 0) {
|
||||
logger.warn('Received empty response from Claude');
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* POST /context/describe-image endpoint - Generate description for an image
|
||||
*
|
||||
* Uses Claude Haiku to analyze an image and generate a concise description
|
||||
* suitable for context file metadata.
|
||||
* Uses AI to analyze an image and generate a concise description
|
||||
* suitable for context file metadata. Model is configurable via
|
||||
* phaseModels.imageDescriptionModel in settings (defaults to Haiku).
|
||||
*
|
||||
* IMPORTANT:
|
||||
* The agent runner (chat/auto-mode) sends images as multi-part content blocks (base64 image blocks),
|
||||
@@ -13,8 +14,10 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { createLogger, readImageAsBase64 } from '@automaker/utils';
|
||||
import { CLAUDE_MODEL_MAP } from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { createCustomOptions } from '../../../lib/sdk-options.js';
|
||||
import { ProviderFactory } from '../../../providers/provider-factory.js';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import * as path from 'path';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
@@ -337,40 +340,89 @@ export function createDescribeImageHandler(
|
||||
'[DescribeImage]'
|
||||
);
|
||||
|
||||
// Use the same centralized option builder used across the server (validates cwd)
|
||||
const sdkOptions = createCustomOptions({
|
||||
cwd,
|
||||
model: CLAUDE_MODEL_MAP.haiku,
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
autoLoadClaudeMd,
|
||||
sandbox: { enabled: true, autoAllowBashIfSandboxed: true },
|
||||
});
|
||||
// Get model from phase settings
|
||||
const settings = await settingsService?.getGlobalSettings();
|
||||
const phaseModelEntry =
|
||||
settings?.phaseModels?.imageDescriptionModel || DEFAULT_PHASE_MODELS.imageDescriptionModel;
|
||||
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] SDK options model=${sdkOptions.model} maxTurns=${sdkOptions.maxTurns} allowedTools=${JSON.stringify(
|
||||
sdkOptions.allowedTools
|
||||
)} sandbox=${JSON.stringify(sdkOptions.sandbox)}`
|
||||
);
|
||||
logger.info(`[${requestId}] Using model: ${model}`);
|
||||
|
||||
const promptGenerator = (async function* () {
|
||||
yield {
|
||||
type: 'user' as const,
|
||||
session_id: '',
|
||||
message: { role: 'user' as const, content: promptContent },
|
||||
parent_tool_use_id: null,
|
||||
};
|
||||
})();
|
||||
let description: string;
|
||||
|
||||
logger.info(`[${requestId}] Calling query()...`);
|
||||
const queryStart = Date.now();
|
||||
const stream = query({ prompt: promptGenerator, options: sdkOptions });
|
||||
logger.info(`[${requestId}] query() returned stream in ${Date.now() - queryStart}ms`);
|
||||
// Route to appropriate provider based on model type
|
||||
if (isCursorModel(model)) {
|
||||
// Use Cursor provider for Cursor models
|
||||
// Note: Cursor may have limited support for image content blocks
|
||||
logger.info(`[${requestId}] Using Cursor provider for model: ${model}`);
|
||||
|
||||
// Extract the description from the response
|
||||
const extractStart = Date.now();
|
||||
const description = await extractTextFromStream(stream, requestId);
|
||||
logger.info(`[${requestId}] extractMs=${Date.now() - extractStart}`);
|
||||
const provider = ProviderFactory.getProviderForModel(model);
|
||||
|
||||
// Build prompt with image reference for Cursor
|
||||
// Note: Cursor CLI may not support base64 image blocks directly,
|
||||
// so we include the image path as context
|
||||
const cursorPrompt = `${instructionText}\n\nImage file: ${actualPath}\nMIME type: ${imageData.mimeType}`;
|
||||
|
||||
let responseText = '';
|
||||
const queryStart = Date.now();
|
||||
for await (const msg of provider.executeQuery({
|
||||
prompt: cursorPrompt,
|
||||
model,
|
||||
cwd,
|
||||
maxTurns: 1,
|
||||
allowedTools: ['Read'], // Allow Read tool so Cursor can read the image if needed
|
||||
readOnly: true, // Image description only reads, doesn't write
|
||||
})) {
|
||||
if (msg.type === 'assistant' && msg.message?.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
responseText += block.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.info(`[${requestId}] Cursor query completed in ${Date.now() - queryStart}ms`);
|
||||
description = responseText;
|
||||
} else {
|
||||
// Use Claude SDK for Claude models (supports image content blocks)
|
||||
logger.info(`[${requestId}] Using Claude SDK for model: ${model}`);
|
||||
|
||||
// Use the same centralized option builder used across the server (validates cwd)
|
||||
const sdkOptions = createCustomOptions({
|
||||
cwd,
|
||||
model,
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
autoLoadClaudeMd,
|
||||
sandbox: { enabled: true, autoAllowBashIfSandboxed: true },
|
||||
thinkingLevel, // Pass thinking level for extended thinking
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] SDK options model=${sdkOptions.model} maxTurns=${sdkOptions.maxTurns} allowedTools=${JSON.stringify(
|
||||
sdkOptions.allowedTools
|
||||
)} sandbox=${JSON.stringify(sdkOptions.sandbox)}`
|
||||
);
|
||||
|
||||
const promptGenerator = (async function* () {
|
||||
yield {
|
||||
type: 'user' as const,
|
||||
session_id: '',
|
||||
message: { role: 'user' as const, content: promptContent },
|
||||
parent_tool_use_id: null,
|
||||
};
|
||||
})();
|
||||
|
||||
logger.info(`[${requestId}] Calling query()...`);
|
||||
const queryStart = Date.now();
|
||||
const stream = query({ prompt: promptGenerator, options: sdkOptions });
|
||||
logger.info(`[${requestId}] query() returned stream in ${Date.now() - queryStart}ms`);
|
||||
|
||||
// Extract the description from the response
|
||||
const extractStart = Date.now();
|
||||
description = await extractTextFromStream(stream, requestId);
|
||||
logger.info(`[${requestId}] extractMs=${Date.now() - extractStart}`);
|
||||
}
|
||||
|
||||
if (!description || description.trim().length === 0) {
|
||||
logger.warn(`[${requestId}] Received empty response from Claude`);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* POST /enhance-prompt endpoint - Enhance user input text
|
||||
*
|
||||
* Uses Claude AI to enhance text based on the specified enhancement mode.
|
||||
* Uses Claude AI or Cursor to enhance text based on the specified enhancement mode.
|
||||
* Supports modes: improve, technical, simplify, acceptance
|
||||
*/
|
||||
|
||||
@@ -9,7 +9,13 @@ import type { Request, Response } from 'express';
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { resolveModelString } from '@automaker/model-resolver';
|
||||
import { CLAUDE_MODEL_MAP } from '@automaker/types';
|
||||
import {
|
||||
CLAUDE_MODEL_MAP,
|
||||
isCursorModel,
|
||||
ThinkingLevel,
|
||||
getThinkingTokenBudget,
|
||||
} from '@automaker/types';
|
||||
import { ProviderFactory } from '../../../providers/provider-factory.js';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import { getPromptCustomization } from '../../../lib/settings-helpers.js';
|
||||
import {
|
||||
@@ -30,6 +36,8 @@ interface EnhanceRequestBody {
|
||||
enhancementMode: string;
|
||||
/** Optional model override */
|
||||
model?: string;
|
||||
/** Optional thinking level for Claude models (ignored for Cursor models) */
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,6 +89,41 @@ async function extractTextFromStream(
|
||||
return responseText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute enhancement using Cursor provider
|
||||
*
|
||||
* @param prompt - The enhancement prompt
|
||||
* @param model - The Cursor model to use
|
||||
* @returns The enhanced text
|
||||
*/
|
||||
async function executeWithCursor(prompt: string, model: string): Promise<string> {
|
||||
const provider = ProviderFactory.getProviderForModel(model);
|
||||
|
||||
let responseText = '';
|
||||
|
||||
for await (const msg of provider.executeQuery({
|
||||
prompt,
|
||||
model,
|
||||
cwd: process.cwd(), // Enhancement doesn't need a specific working directory
|
||||
readOnly: true, // Prompt enhancement only generates text, doesn't write files
|
||||
})) {
|
||||
if (msg.type === 'assistant' && msg.message?.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
responseText += block.text;
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
|
||||
// Use result if it's a final accumulated message
|
||||
if (msg.result.length > responseText.length) {
|
||||
responseText = msg.result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return responseText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the enhance request handler
|
||||
*
|
||||
@@ -92,7 +135,8 @@ export function createEnhanceHandler(
|
||||
): (req: Request, res: Response) => Promise<void> {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { originalText, enhancementMode, model } = req.body as EnhanceRequestBody;
|
||||
const { originalText, enhancementMode, model, thinkingLevel } =
|
||||
req.body as EnhanceRequestBody;
|
||||
|
||||
// Validate required fields
|
||||
if (!originalText || typeof originalText !== 'string') {
|
||||
@@ -155,24 +199,43 @@ export function createEnhanceHandler(
|
||||
|
||||
logger.debug(`Using model: ${resolvedModel}`);
|
||||
|
||||
// Call Claude SDK with minimal configuration for text transformation
|
||||
// Key: no tools, just text completion
|
||||
const stream = query({
|
||||
prompt: userPrompt,
|
||||
options: {
|
||||
let enhancedText: string;
|
||||
|
||||
// Route to appropriate provider based on model
|
||||
if (isCursorModel(resolvedModel)) {
|
||||
// Use Cursor provider for Cursor models
|
||||
logger.info(`Using Cursor provider for model: ${resolvedModel}`);
|
||||
|
||||
// Cursor doesn't have a separate system prompt concept, so combine them
|
||||
const combinedPrompt = `${systemPrompt}\n\n${userPrompt}`;
|
||||
enhancedText = await executeWithCursor(combinedPrompt, resolvedModel);
|
||||
} else {
|
||||
// Use Claude SDK for Claude models
|
||||
logger.info(`Using Claude provider for model: ${resolvedModel}`);
|
||||
|
||||
// Convert thinkingLevel to maxThinkingTokens for SDK
|
||||
const maxThinkingTokens = getThinkingTokenBudget(thinkingLevel);
|
||||
const queryOptions: Parameters<typeof query>[0]['options'] = {
|
||||
model: resolvedModel,
|
||||
systemPrompt,
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
permissionMode: 'acceptEdits',
|
||||
},
|
||||
});
|
||||
};
|
||||
if (maxThinkingTokens) {
|
||||
queryOptions.maxThinkingTokens = maxThinkingTokens;
|
||||
}
|
||||
|
||||
// Extract the enhanced text from the response
|
||||
const enhancedText = await extractTextFromStream(stream);
|
||||
const stream = query({
|
||||
prompt: userPrompt,
|
||||
options: queryOptions,
|
||||
});
|
||||
|
||||
enhancedText = await extractTextFromStream(stream);
|
||||
}
|
||||
|
||||
if (!enhancedText || enhancedText.trim().length === 0) {
|
||||
logger.warn('Received empty response from Claude');
|
||||
logger.warn('Received empty response from AI');
|
||||
const response: EnhanceErrorResponse = {
|
||||
success: false,
|
||||
error: 'Failed to generate enhanced text - empty response',
|
||||
|
||||
@@ -10,7 +10,7 @@ import { createGetHandler } from './routes/get.js';
|
||||
import { createCreateHandler } from './routes/create.js';
|
||||
import { createUpdateHandler } from './routes/update.js';
|
||||
import { createDeleteHandler } from './routes/delete.js';
|
||||
import { createAgentOutputHandler } from './routes/agent-output.js';
|
||||
import { createAgentOutputHandler, createRawOutputHandler } from './routes/agent-output.js';
|
||||
import { createGenerateTitleHandler } from './routes/generate-title.js';
|
||||
|
||||
export function createFeaturesRoutes(featureLoader: FeatureLoader): Router {
|
||||
@@ -22,6 +22,7 @@ export function createFeaturesRoutes(featureLoader: FeatureLoader): Router {
|
||||
router.post('/update', validatePathParams('projectPath'), createUpdateHandler(featureLoader));
|
||||
router.post('/delete', validatePathParams('projectPath'), createDeleteHandler(featureLoader));
|
||||
router.post('/agent-output', createAgentOutputHandler(featureLoader));
|
||||
router.post('/raw-output', createRawOutputHandler(featureLoader));
|
||||
router.post('/generate-title', createGenerateTitleHandler());
|
||||
|
||||
return router;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* POST /agent-output endpoint - Get agent output for a feature
|
||||
* POST /raw-output endpoint - Get raw JSONL output for debugging
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
@@ -30,3 +31,31 @@ export function createAgentOutputHandler(featureLoader: FeatureLoader) {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for getting raw JSONL output for debugging
|
||||
*/
|
||||
export function createRawOutputHandler(featureLoader: FeatureLoader) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, featureId } = req.body as {
|
||||
projectPath: string;
|
||||
featureId: string;
|
||||
};
|
||||
|
||||
if (!projectPath || !featureId) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'projectPath and featureId are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await featureLoader.getRawOutput(projectPath, featureId);
|
||||
res.json({ success: true, content });
|
||||
} catch (error) {
|
||||
logError(error, 'Get raw output failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ export function createGenerateTitleHandler(): (req: Request, res: Response) => P
|
||||
systemPrompt: SYSTEM_PROMPT,
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
permissionMode: 'acceptEdits',
|
||||
permissionMode: 'default',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('GitHub');
|
||||
|
||||
export const execAsync = promisify(exec);
|
||||
|
||||
@@ -31,5 +34,5 @@ export function getErrorMessage(error: unknown): string {
|
||||
}
|
||||
|
||||
export function logError(error: unknown, context: string): void {
|
||||
console.error(`[GitHub] ${context}:`, error);
|
||||
logger.error(`${context}:`, error);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ import { spawn } from 'child_process';
|
||||
import type { Request, Response } from 'express';
|
||||
import { execAsync, execEnv, getErrorMessage, logError } from './common.js';
|
||||
import { checkGitHubRemote } from './check-github-remote.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('ListIssues');
|
||||
|
||||
export interface GitHubLabel {
|
||||
name: string;
|
||||
@@ -179,7 +182,7 @@ async function fetchLinkedPRs(
|
||||
}
|
||||
} catch (error) {
|
||||
// If GraphQL fails, continue without linked PRs
|
||||
console.warn(
|
||||
logger.warn(
|
||||
'Failed to fetch linked PRs via GraphQL:',
|
||||
error instanceof Error ? error.message : error
|
||||
);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* POST /validate-issue endpoint - Validate a GitHub issue using Claude SDK (async)
|
||||
* POST /validate-issue endpoint - Validate a GitHub issue using Claude SDK or Cursor (async)
|
||||
*
|
||||
* Scans the codebase to determine if an issue is valid, invalid, or needs clarification.
|
||||
* Runs asynchronously and emits events for progress and completion.
|
||||
* Supports both Claude models and Cursor models.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
@@ -11,12 +12,18 @@ import type { EventEmitter } from '../../../lib/events.js';
|
||||
import type {
|
||||
IssueValidationResult,
|
||||
IssueValidationEvent,
|
||||
AgentModel,
|
||||
ModelAlias,
|
||||
CursorModelId,
|
||||
GitHubComment,
|
||||
LinkedPRInfo,
|
||||
ThinkingLevel,
|
||||
} from '@automaker/types';
|
||||
import { isCursorModel, DEFAULT_PHASE_MODELS } from '@automaker/types';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { createSuggestionsOptions } from '../../../lib/sdk-options.js';
|
||||
import { extractJson } from '../../../lib/json-extractor.js';
|
||||
import { writeValidation } from '../../../lib/validation-storage.js';
|
||||
import { ProviderFactory } from '../../../providers/provider-factory.js';
|
||||
import {
|
||||
issueValidationSchema,
|
||||
ISSUE_VALIDATION_SYSTEM_PROMPT,
|
||||
@@ -34,8 +41,8 @@ import {
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.js';
|
||||
|
||||
/** Valid model values for validation */
|
||||
const VALID_MODELS: readonly AgentModel[] = ['opus', 'sonnet', 'haiku'] as const;
|
||||
/** Valid Claude model values for validation */
|
||||
const VALID_CLAUDE_MODELS: readonly ModelAlias[] = ['opus', 'sonnet', 'haiku'] as const;
|
||||
|
||||
/**
|
||||
* Request body for issue validation
|
||||
@@ -46,8 +53,10 @@ interface ValidateIssueRequestBody {
|
||||
issueTitle: string;
|
||||
issueBody: string;
|
||||
issueLabels?: string[];
|
||||
/** Model to use for validation (opus, sonnet, haiku) */
|
||||
model?: AgentModel;
|
||||
/** Model to use for validation (opus, sonnet, haiku, or cursor model IDs) */
|
||||
model?: ModelAlias | CursorModelId;
|
||||
/** Thinking level for Claude models (ignored for Cursor models) */
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
/** Comments to include in validation analysis */
|
||||
comments?: GitHubComment[];
|
||||
/** Linked pull requests for this issue */
|
||||
@@ -59,6 +68,7 @@ interface ValidateIssueRequestBody {
|
||||
*
|
||||
* Emits events for start, progress, complete, and error.
|
||||
* Stores result on completion.
|
||||
* Supports both Claude models (with structured output) and Cursor models (with JSON parsing).
|
||||
*/
|
||||
async function runValidation(
|
||||
projectPath: string,
|
||||
@@ -66,12 +76,13 @@ async function runValidation(
|
||||
issueTitle: string,
|
||||
issueBody: string,
|
||||
issueLabels: string[] | undefined,
|
||||
model: AgentModel,
|
||||
model: ModelAlias | CursorModelId,
|
||||
events: EventEmitter,
|
||||
abortController: AbortController,
|
||||
settingsService?: SettingsService,
|
||||
comments?: ValidationComment[],
|
||||
linkedPRs?: ValidationLinkedPR[]
|
||||
linkedPRs?: ValidationLinkedPR[],
|
||||
thinkingLevel?: ThinkingLevel
|
||||
): Promise<void> {
|
||||
// Emit start event
|
||||
const startEvent: IssueValidationEvent = {
|
||||
@@ -100,60 +111,136 @@ async function runValidation(
|
||||
linkedPRs
|
||||
);
|
||||
|
||||
// Load autoLoadClaudeMd setting
|
||||
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
|
||||
projectPath,
|
||||
settingsService,
|
||||
'[ValidateIssue]'
|
||||
);
|
||||
|
||||
// Create SDK options with structured output and abort controller
|
||||
const options = createSuggestionsOptions({
|
||||
cwd: projectPath,
|
||||
model,
|
||||
systemPrompt: ISSUE_VALIDATION_SYSTEM_PROMPT,
|
||||
abortController,
|
||||
autoLoadClaudeMd,
|
||||
outputFormat: {
|
||||
type: 'json_schema',
|
||||
schema: issueValidationSchema as Record<string, unknown>,
|
||||
},
|
||||
});
|
||||
|
||||
// Execute the query
|
||||
const stream = query({ prompt, options });
|
||||
let validationResult: IssueValidationResult | null = null;
|
||||
let responseText = '';
|
||||
|
||||
for await (const msg of stream) {
|
||||
// Emit progress events for assistant text
|
||||
if (msg.type === 'assistant' && msg.message?.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === 'text') {
|
||||
const progressEvent: IssueValidationEvent = {
|
||||
type: 'issue_validation_progress',
|
||||
issueNumber,
|
||||
content: block.text,
|
||||
projectPath,
|
||||
};
|
||||
events.emit('issue-validation:event', progressEvent);
|
||||
// Route to appropriate provider based on model
|
||||
if (isCursorModel(model)) {
|
||||
// Use Cursor provider for Cursor models
|
||||
logger.info(`Using Cursor provider for validation with model: ${model}`);
|
||||
|
||||
const provider = ProviderFactory.getProviderForModel(model);
|
||||
|
||||
// For Cursor, include the system prompt and schema in the user prompt
|
||||
const cursorPrompt = `${ISSUE_VALIDATION_SYSTEM_PROMPT}
|
||||
|
||||
CRITICAL INSTRUCTIONS:
|
||||
1. DO NOT write any files. Return the JSON in your response only.
|
||||
2. Respond with ONLY a JSON object - no explanations, no markdown, just raw JSON.
|
||||
3. The JSON must match this exact schema:
|
||||
|
||||
${JSON.stringify(issueValidationSchema, null, 2)}
|
||||
|
||||
Your entire response should be valid JSON starting with { and ending with }. No text before or after.
|
||||
|
||||
${prompt}`;
|
||||
|
||||
for await (const msg of provider.executeQuery({
|
||||
prompt: cursorPrompt,
|
||||
model,
|
||||
cwd: projectPath,
|
||||
readOnly: true, // Issue validation only reads code, doesn't write
|
||||
})) {
|
||||
if (msg.type === 'assistant' && msg.message?.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
responseText += block.text;
|
||||
|
||||
// Emit progress event
|
||||
const progressEvent: IssueValidationEvent = {
|
||||
type: 'issue_validation_progress',
|
||||
issueNumber,
|
||||
content: block.text,
|
||||
projectPath,
|
||||
};
|
||||
events.emit('issue-validation:event', progressEvent);
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
|
||||
// Use result if it's a final accumulated message
|
||||
if (msg.result.length > responseText.length) {
|
||||
responseText = msg.result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract structured output on success
|
||||
if (msg.type === 'result' && msg.subtype === 'success') {
|
||||
const resultMsg = msg as { structured_output?: IssueValidationResult };
|
||||
if (resultMsg.structured_output) {
|
||||
validationResult = resultMsg.structured_output;
|
||||
}
|
||||
// Parse JSON from the response text using shared utility
|
||||
if (responseText) {
|
||||
validationResult = extractJson<IssueValidationResult>(responseText, { logger });
|
||||
}
|
||||
} else {
|
||||
// Use Claude SDK for Claude models
|
||||
logger.info(`Using Claude provider for validation with model: ${model}`);
|
||||
|
||||
// Load autoLoadClaudeMd setting
|
||||
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
|
||||
projectPath,
|
||||
settingsService,
|
||||
'[ValidateIssue]'
|
||||
);
|
||||
|
||||
// Use thinkingLevel from request if provided, otherwise fall back to settings
|
||||
let effectiveThinkingLevel: ThinkingLevel | undefined = thinkingLevel;
|
||||
if (!effectiveThinkingLevel) {
|
||||
const settings = await settingsService?.getGlobalSettings();
|
||||
const phaseModelEntry =
|
||||
settings?.phaseModels?.validationModel || DEFAULT_PHASE_MODELS.validationModel;
|
||||
const resolved = resolvePhaseModel(phaseModelEntry);
|
||||
effectiveThinkingLevel = resolved.thinkingLevel;
|
||||
}
|
||||
|
||||
// Handle errors
|
||||
if (msg.type === 'result') {
|
||||
const resultMsg = msg as { subtype?: string };
|
||||
if (resultMsg.subtype === 'error_max_structured_output_retries') {
|
||||
logger.error('Failed to produce valid structured output after retries');
|
||||
throw new Error('Could not produce valid validation output');
|
||||
// Create SDK options with structured output and abort controller
|
||||
const options = createSuggestionsOptions({
|
||||
cwd: projectPath,
|
||||
model: model as ModelAlias,
|
||||
systemPrompt: ISSUE_VALIDATION_SYSTEM_PROMPT,
|
||||
abortController,
|
||||
autoLoadClaudeMd,
|
||||
thinkingLevel: effectiveThinkingLevel,
|
||||
outputFormat: {
|
||||
type: 'json_schema',
|
||||
schema: issueValidationSchema as Record<string, unknown>,
|
||||
},
|
||||
});
|
||||
|
||||
// Execute the query
|
||||
const stream = query({ prompt, options });
|
||||
|
||||
for await (const msg of stream) {
|
||||
// Collect assistant text for debugging and emit progress
|
||||
if (msg.type === 'assistant' && msg.message?.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === 'text') {
|
||||
responseText += block.text;
|
||||
|
||||
// Emit progress event
|
||||
const progressEvent: IssueValidationEvent = {
|
||||
type: 'issue_validation_progress',
|
||||
issueNumber,
|
||||
content: block.text,
|
||||
projectPath,
|
||||
};
|
||||
events.emit('issue-validation:event', progressEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract structured output on success
|
||||
if (msg.type === 'result' && msg.subtype === 'success') {
|
||||
const resultMsg = msg as { structured_output?: IssueValidationResult };
|
||||
if (resultMsg.structured_output) {
|
||||
validationResult = resultMsg.structured_output;
|
||||
logger.debug('Received structured output:', validationResult);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle errors
|
||||
if (msg.type === 'result') {
|
||||
const resultMsg = msg as { subtype?: string };
|
||||
if (resultMsg.subtype === 'error_max_structured_output_retries') {
|
||||
logger.error('Failed to produce valid structured output after retries');
|
||||
throw new Error('Could not produce valid validation output');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -161,10 +248,10 @@ async function runValidation(
|
||||
// Clear timeout
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
// Require structured output
|
||||
// Require validation result
|
||||
if (!validationResult) {
|
||||
logger.error('No structured output received from Claude SDK');
|
||||
throw new Error('Validation failed: no structured output received');
|
||||
logger.error('No validation result received from AI provider');
|
||||
throw new Error('Validation failed: no valid result received');
|
||||
}
|
||||
|
||||
logger.info(`Issue #${issueNumber} validation complete: ${validationResult.verdict}`);
|
||||
@@ -229,6 +316,7 @@ export function createValidateIssueHandler(
|
||||
issueBody,
|
||||
issueLabels,
|
||||
model = 'opus',
|
||||
thinkingLevel,
|
||||
comments: rawComments,
|
||||
linkedPRs: rawLinkedPRs,
|
||||
} = req.body as ValidateIssueRequestBody;
|
||||
@@ -276,11 +364,14 @@ export function createValidateIssueHandler(
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate model parameter at runtime
|
||||
if (!VALID_MODELS.includes(model)) {
|
||||
// Validate model parameter at runtime - accept Claude models or Cursor models
|
||||
const isValidClaudeModel = VALID_CLAUDE_MODELS.includes(model as ModelAlias);
|
||||
const isValidCursorModel = isCursorModel(model);
|
||||
|
||||
if (!isValidClaudeModel && !isValidCursorModel) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `Invalid model. Must be one of: ${VALID_MODELS.join(', ')}`,
|
||||
error: `Invalid model. Must be one of: ${VALID_CLAUDE_MODELS.join(', ')}, or a Cursor model ID`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -310,7 +401,8 @@ export function createValidateIssueHandler(
|
||||
abortController,
|
||||
settingsService,
|
||||
validationComments,
|
||||
validationLinkedPRs
|
||||
validationLinkedPRs,
|
||||
thinkingLevel
|
||||
)
|
||||
.catch(() => {
|
||||
// Error is already handled inside runValidation (event emitted)
|
||||
|
||||
12
apps/server/src/routes/ideation/common.ts
Normal file
12
apps/server/src/routes/ideation/common.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Common utilities for ideation routes
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
|
||||
|
||||
const logger = createLogger('Ideation');
|
||||
|
||||
// Re-export shared utilities
|
||||
export { getErrorMessageShared as getErrorMessage };
|
||||
export const logError = createLogError(logger);
|
||||
109
apps/server/src/routes/ideation/index.ts
Normal file
109
apps/server/src/routes/ideation/index.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Ideation routes - HTTP API for brainstorming and idea management
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import { validatePathParams } from '../../middleware/validate-paths.js';
|
||||
import type { IdeationService } from '../../services/ideation-service.js';
|
||||
import type { FeatureLoader } from '../../services/feature-loader.js';
|
||||
|
||||
// Route handlers
|
||||
import { createSessionStartHandler } from './routes/session-start.js';
|
||||
import { createSessionMessageHandler } from './routes/session-message.js';
|
||||
import { createSessionStopHandler } from './routes/session-stop.js';
|
||||
import { createSessionGetHandler } from './routes/session-get.js';
|
||||
import { createIdeasListHandler } from './routes/ideas-list.js';
|
||||
import { createIdeasCreateHandler } from './routes/ideas-create.js';
|
||||
import { createIdeasGetHandler } from './routes/ideas-get.js';
|
||||
import { createIdeasUpdateHandler } from './routes/ideas-update.js';
|
||||
import { createIdeasDeleteHandler } from './routes/ideas-delete.js';
|
||||
import { createAnalyzeHandler, createGetAnalysisHandler } from './routes/analyze.js';
|
||||
import { createConvertHandler } from './routes/convert.js';
|
||||
import { createAddSuggestionHandler } from './routes/add-suggestion.js';
|
||||
import { createPromptsHandler, createPromptsByCategoryHandler } from './routes/prompts.js';
|
||||
import { createSuggestionsGenerateHandler } from './routes/suggestions-generate.js';
|
||||
|
||||
export function createIdeationRoutes(
|
||||
events: EventEmitter,
|
||||
ideationService: IdeationService,
|
||||
featureLoader: FeatureLoader
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
// Session management
|
||||
router.post(
|
||||
'/session/start',
|
||||
validatePathParams('projectPath'),
|
||||
createSessionStartHandler(ideationService)
|
||||
);
|
||||
router.post('/session/message', createSessionMessageHandler(ideationService));
|
||||
router.post('/session/stop', createSessionStopHandler(events, ideationService));
|
||||
router.post(
|
||||
'/session/get',
|
||||
validatePathParams('projectPath'),
|
||||
createSessionGetHandler(ideationService)
|
||||
);
|
||||
|
||||
// Ideas CRUD
|
||||
router.post(
|
||||
'/ideas/list',
|
||||
validatePathParams('projectPath'),
|
||||
createIdeasListHandler(ideationService)
|
||||
);
|
||||
router.post(
|
||||
'/ideas/create',
|
||||
validatePathParams('projectPath'),
|
||||
createIdeasCreateHandler(events, ideationService)
|
||||
);
|
||||
router.post(
|
||||
'/ideas/get',
|
||||
validatePathParams('projectPath'),
|
||||
createIdeasGetHandler(ideationService)
|
||||
);
|
||||
router.post(
|
||||
'/ideas/update',
|
||||
validatePathParams('projectPath'),
|
||||
createIdeasUpdateHandler(events, ideationService)
|
||||
);
|
||||
router.post(
|
||||
'/ideas/delete',
|
||||
validatePathParams('projectPath'),
|
||||
createIdeasDeleteHandler(events, ideationService)
|
||||
);
|
||||
|
||||
// Project analysis
|
||||
router.post('/analyze', validatePathParams('projectPath'), createAnalyzeHandler(ideationService));
|
||||
router.post(
|
||||
'/analysis',
|
||||
validatePathParams('projectPath'),
|
||||
createGetAnalysisHandler(ideationService)
|
||||
);
|
||||
|
||||
// Convert to feature
|
||||
router.post(
|
||||
'/convert',
|
||||
validatePathParams('projectPath'),
|
||||
createConvertHandler(events, ideationService, featureLoader)
|
||||
);
|
||||
|
||||
// Add suggestion to board as a feature
|
||||
router.post(
|
||||
'/add-suggestion',
|
||||
validatePathParams('projectPath'),
|
||||
createAddSuggestionHandler(ideationService, featureLoader)
|
||||
);
|
||||
|
||||
// Guided prompts (no validation needed - static data)
|
||||
router.get('/prompts', createPromptsHandler(ideationService));
|
||||
router.get('/prompts/:category', createPromptsByCategoryHandler(ideationService));
|
||||
|
||||
// Generate suggestions (structured output)
|
||||
router.post(
|
||||
'/suggestions/generate',
|
||||
validatePathParams('projectPath'),
|
||||
createSuggestionsGenerateHandler(ideationService)
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
70
apps/server/src/routes/ideation/routes/add-suggestion.ts
Normal file
70
apps/server/src/routes/ideation/routes/add-suggestion.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* POST /add-suggestion - Add an analysis suggestion to the board as a feature
|
||||
*
|
||||
* This endpoint converts an AnalysisSuggestion to a Feature using the
|
||||
* IdeationService's mapIdeaCategoryToFeatureCategory for consistent category mapping.
|
||||
* This ensures a single source of truth for the conversion logic.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { IdeationService } from '../../../services/ideation-service.js';
|
||||
import type { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import type { AnalysisSuggestion } from '@automaker/types';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createAddSuggestionHandler(
|
||||
ideationService: IdeationService,
|
||||
featureLoader: FeatureLoader
|
||||
) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, suggestion } = req.body as {
|
||||
projectPath: string;
|
||||
suggestion: AnalysisSuggestion;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!suggestion) {
|
||||
res.status(400).json({ success: false, error: 'suggestion is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!suggestion.title) {
|
||||
res.status(400).json({ success: false, error: 'suggestion.title is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!suggestion.category) {
|
||||
res.status(400).json({ success: false, error: 'suggestion.category is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Build description with rationale if provided
|
||||
const description = suggestion.rationale
|
||||
? `${suggestion.description}\n\n**Rationale:** ${suggestion.rationale}`
|
||||
: suggestion.description;
|
||||
|
||||
// Use the service's category mapping for consistency
|
||||
const featureCategory = ideationService.mapSuggestionCategoryToFeatureCategory(
|
||||
suggestion.category
|
||||
);
|
||||
|
||||
// Create the feature
|
||||
const feature = await featureLoader.create(projectPath, {
|
||||
title: suggestion.title,
|
||||
description,
|
||||
category: featureCategory,
|
||||
status: 'backlog',
|
||||
});
|
||||
|
||||
res.json({ success: true, featureId: feature.id });
|
||||
} catch (error) {
|
||||
logError(error, 'Add suggestion to board failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
49
apps/server/src/routes/ideation/routes/analyze.ts
Normal file
49
apps/server/src/routes/ideation/routes/analyze.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* POST /analyze - Analyze project and generate suggestions
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { IdeationService } from '../../../services/ideation-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createAnalyzeHandler(ideationService: IdeationService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath } = req.body as { projectPath: string };
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Start analysis - results come via WebSocket events
|
||||
ideationService.analyzeProject(projectPath).catch((error) => {
|
||||
logError(error, 'Analyze project failed (async)');
|
||||
});
|
||||
|
||||
res.json({ success: true, message: 'Analysis started' });
|
||||
} catch (error) {
|
||||
logError(error, 'Analyze project failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createGetAnalysisHandler(ideationService: IdeationService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath } = req.body as { projectPath: string };
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ideationService.getCachedAnalysis(projectPath);
|
||||
res.json({ success: true, result });
|
||||
} catch (error) {
|
||||
logError(error, 'Get analysis failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
77
apps/server/src/routes/ideation/routes/convert.ts
Normal file
77
apps/server/src/routes/ideation/routes/convert.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* POST /convert - Convert an idea to a feature
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { EventEmitter } from '../../../lib/events.js';
|
||||
import type { IdeationService } from '../../../services/ideation-service.js';
|
||||
import type { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import type { ConvertToFeatureOptions } from '@automaker/types';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createConvertHandler(
|
||||
events: EventEmitter,
|
||||
ideationService: IdeationService,
|
||||
featureLoader: FeatureLoader
|
||||
) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, ideaId, keepIdea, column, dependencies, tags } = req.body as {
|
||||
projectPath: string;
|
||||
ideaId: string;
|
||||
} & ConvertToFeatureOptions;
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ideaId) {
|
||||
res.status(400).json({ success: false, error: 'ideaId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert idea to feature structure
|
||||
const featureData = await ideationService.convertToFeature(projectPath, ideaId);
|
||||
|
||||
// Apply any options from the request
|
||||
if (column) {
|
||||
featureData.status = column;
|
||||
}
|
||||
if (dependencies && dependencies.length > 0) {
|
||||
featureData.dependencies = dependencies;
|
||||
}
|
||||
if (tags && tags.length > 0) {
|
||||
featureData.tags = tags;
|
||||
}
|
||||
|
||||
// Create the feature using FeatureLoader
|
||||
const feature = await featureLoader.create(projectPath, featureData);
|
||||
|
||||
// Delete the idea unless keepIdea is explicitly true
|
||||
if (!keepIdea) {
|
||||
await ideationService.deleteIdea(projectPath, ideaId);
|
||||
|
||||
// Emit idea deleted event
|
||||
events.emit('ideation:idea-deleted', {
|
||||
projectPath,
|
||||
ideaId,
|
||||
});
|
||||
}
|
||||
|
||||
// Emit idea converted event to notify frontend
|
||||
events.emit('ideation:idea-converted', {
|
||||
projectPath,
|
||||
ideaId,
|
||||
featureId: feature.id,
|
||||
keepIdea: !!keepIdea,
|
||||
});
|
||||
|
||||
// Return featureId as expected by the frontend API interface
|
||||
res.json({ success: true, featureId: feature.id });
|
||||
} catch (error) {
|
||||
logError(error, 'Convert to feature failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
51
apps/server/src/routes/ideation/routes/ideas-create.ts
Normal file
51
apps/server/src/routes/ideation/routes/ideas-create.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* POST /ideas/create - Create a new idea
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { EventEmitter } from '../../../lib/events.js';
|
||||
import type { IdeationService } from '../../../services/ideation-service.js';
|
||||
import type { CreateIdeaInput } from '@automaker/types';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createIdeasCreateHandler(events: EventEmitter, ideationService: IdeationService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, idea } = req.body as {
|
||||
projectPath: string;
|
||||
idea: CreateIdeaInput;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!idea) {
|
||||
res.status(400).json({ success: false, error: 'idea is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!idea.title || !idea.description || !idea.category) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'idea must have title, description, and category',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const created = await ideationService.createIdea(projectPath, idea);
|
||||
|
||||
// Emit idea created event for frontend notification
|
||||
events.emit('ideation:idea-created', {
|
||||
projectPath,
|
||||
idea: created,
|
||||
});
|
||||
|
||||
res.json({ success: true, idea: created });
|
||||
} catch (error) {
|
||||
logError(error, 'Create idea failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
42
apps/server/src/routes/ideation/routes/ideas-delete.ts
Normal file
42
apps/server/src/routes/ideation/routes/ideas-delete.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* POST /ideas/delete - Delete an idea
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { EventEmitter } from '../../../lib/events.js';
|
||||
import type { IdeationService } from '../../../services/ideation-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createIdeasDeleteHandler(events: EventEmitter, ideationService: IdeationService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, ideaId } = req.body as {
|
||||
projectPath: string;
|
||||
ideaId: string;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ideaId) {
|
||||
res.status(400).json({ success: false, error: 'ideaId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
await ideationService.deleteIdea(projectPath, ideaId);
|
||||
|
||||
// Emit idea deleted event for frontend notification
|
||||
events.emit('ideation:idea-deleted', {
|
||||
projectPath,
|
||||
ideaId,
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logError(error, 'Delete idea failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
39
apps/server/src/routes/ideation/routes/ideas-get.ts
Normal file
39
apps/server/src/routes/ideation/routes/ideas-get.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* POST /ideas/get - Get a single idea
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { IdeationService } from '../../../services/ideation-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createIdeasGetHandler(ideationService: IdeationService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, ideaId } = req.body as {
|
||||
projectPath: string;
|
||||
ideaId: string;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ideaId) {
|
||||
res.status(400).json({ success: false, error: 'ideaId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const idea = await ideationService.getIdea(projectPath, ideaId);
|
||||
if (!idea) {
|
||||
res.status(404).json({ success: false, error: 'Idea not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ success: true, idea });
|
||||
} catch (error) {
|
||||
logError(error, 'Get idea failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
26
apps/server/src/routes/ideation/routes/ideas-list.ts
Normal file
26
apps/server/src/routes/ideation/routes/ideas-list.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* POST /ideas/list - List all ideas for a project
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { IdeationService } from '../../../services/ideation-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createIdeasListHandler(ideationService: IdeationService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath } = req.body as { projectPath: string };
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const ideas = await ideationService.getIdeas(projectPath);
|
||||
res.json({ success: true, ideas });
|
||||
} catch (error) {
|
||||
logError(error, 'List ideas failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
54
apps/server/src/routes/ideation/routes/ideas-update.ts
Normal file
54
apps/server/src/routes/ideation/routes/ideas-update.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* POST /ideas/update - Update an idea
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { EventEmitter } from '../../../lib/events.js';
|
||||
import type { IdeationService } from '../../../services/ideation-service.js';
|
||||
import type { UpdateIdeaInput } from '@automaker/types';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createIdeasUpdateHandler(events: EventEmitter, ideationService: IdeationService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, ideaId, updates } = req.body as {
|
||||
projectPath: string;
|
||||
ideaId: string;
|
||||
updates: UpdateIdeaInput;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ideaId) {
|
||||
res.status(400).json({ success: false, error: 'ideaId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!updates) {
|
||||
res.status(400).json({ success: false, error: 'updates is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const idea = await ideationService.updateIdea(projectPath, ideaId, updates);
|
||||
if (!idea) {
|
||||
res.status(404).json({ success: false, error: 'Idea not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Emit idea updated event for frontend notification
|
||||
events.emit('ideation:idea-updated', {
|
||||
projectPath,
|
||||
ideaId,
|
||||
idea,
|
||||
});
|
||||
|
||||
res.json({ success: true, idea });
|
||||
} catch (error) {
|
||||
logError(error, 'Update idea failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
42
apps/server/src/routes/ideation/routes/prompts.ts
Normal file
42
apps/server/src/routes/ideation/routes/prompts.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* GET /prompts - Get all guided prompts
|
||||
* GET /prompts/:category - Get prompts for a specific category
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { IdeationService } from '../../../services/ideation-service.js';
|
||||
import type { IdeaCategory } from '@automaker/types';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createPromptsHandler(ideationService: IdeationService) {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const prompts = ideationService.getAllPrompts();
|
||||
const categories = ideationService.getPromptCategories();
|
||||
res.json({ success: true, prompts, categories });
|
||||
} catch (error) {
|
||||
logError(error, 'Get prompts failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createPromptsByCategoryHandler(ideationService: IdeationService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { category } = req.params as { category: string };
|
||||
|
||||
const validCategories = ideationService.getPromptCategories().map((c) => c.id);
|
||||
if (!validCategories.includes(category as IdeaCategory)) {
|
||||
res.status(400).json({ success: false, error: 'Invalid category' });
|
||||
return;
|
||||
}
|
||||
|
||||
const prompts = ideationService.getPromptsByCategory(category as IdeaCategory);
|
||||
res.json({ success: true, prompts });
|
||||
} catch (error) {
|
||||
logError(error, 'Get prompts by category failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
45
apps/server/src/routes/ideation/routes/session-get.ts
Normal file
45
apps/server/src/routes/ideation/routes/session-get.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* POST /session/get - Get an ideation session with messages
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { IdeationService } from '../../../services/ideation-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createSessionGetHandler(ideationService: IdeationService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, sessionId } = req.body as {
|
||||
projectPath: string;
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
res.status(400).json({ success: false, error: 'sessionId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await ideationService.getSession(projectPath, sessionId);
|
||||
if (!session) {
|
||||
res.status(404).json({ success: false, error: 'Session not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const isRunning = ideationService.isSessionRunning(sessionId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
session: { ...session, isRunning },
|
||||
messages: session.messages,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Get session failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
40
apps/server/src/routes/ideation/routes/session-message.ts
Normal file
40
apps/server/src/routes/ideation/routes/session-message.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* POST /session/message - Send a message in an ideation session
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { IdeationService } from '../../../services/ideation-service.js';
|
||||
import type { SendMessageOptions } from '@automaker/types';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createSessionMessageHandler(ideationService: IdeationService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { sessionId, message, options } = req.body as {
|
||||
sessionId: string;
|
||||
message: string;
|
||||
options?: SendMessageOptions;
|
||||
};
|
||||
|
||||
if (!sessionId) {
|
||||
res.status(400).json({ success: false, error: 'sessionId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!message) {
|
||||
res.status(400).json({ success: false, error: 'message is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// This is async but we don't await - responses come via WebSocket
|
||||
ideationService.sendMessage(sessionId, message, options).catch((error) => {
|
||||
logError(error, 'Send message failed (async)');
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logError(error, 'Send message failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
30
apps/server/src/routes/ideation/routes/session-start.ts
Normal file
30
apps/server/src/routes/ideation/routes/session-start.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* POST /session/start - Start a new ideation session
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { IdeationService } from '../../../services/ideation-service.js';
|
||||
import type { StartSessionOptions } from '@automaker/types';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createSessionStartHandler(ideationService: IdeationService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, options } = req.body as {
|
||||
projectPath: string;
|
||||
options?: StartSessionOptions;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await ideationService.startSession(projectPath, options);
|
||||
res.json({ success: true, session });
|
||||
} catch (error) {
|
||||
logError(error, 'Start session failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
39
apps/server/src/routes/ideation/routes/session-stop.ts
Normal file
39
apps/server/src/routes/ideation/routes/session-stop.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* POST /session/stop - Stop an ideation session
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { EventEmitter } from '../../../lib/events.js';
|
||||
import type { IdeationService } from '../../../services/ideation-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createSessionStopHandler(events: EventEmitter, ideationService: IdeationService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { sessionId, projectPath } = req.body as {
|
||||
sessionId: string;
|
||||
projectPath?: string;
|
||||
};
|
||||
|
||||
if (!sessionId) {
|
||||
res.status(400).json({ success: false, error: 'sessionId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
await ideationService.stopSession(sessionId);
|
||||
|
||||
// Emit session stopped event for frontend notification
|
||||
// Note: The service also emits 'ideation:session-ended' internally,
|
||||
// but we emit here as well for route-level consistency with other routes
|
||||
events.emit('ideation:session-ended', {
|
||||
sessionId,
|
||||
projectPath,
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logError(error, 'Stop session failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Generate suggestions route - Returns structured AI suggestions for a prompt
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { IdeationService } from '../../../services/ideation-service.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const logger = createLogger('ideation:suggestions-generate');
|
||||
|
||||
export function createSuggestionsGenerateHandler(ideationService: IdeationService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, promptId, category, count } = req.body;
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!promptId) {
|
||||
res.status(400).json({ success: false, error: 'promptId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!category) {
|
||||
res.status(400).json({ success: false, error: 'category is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Default to 10 suggestions, allow 1-20
|
||||
const suggestionCount = Math.min(Math.max(count || 10, 1), 20);
|
||||
|
||||
logger.info(`Generating ${suggestionCount} suggestions for prompt: ${promptId}`);
|
||||
|
||||
const suggestions = await ideationService.generateSuggestions(
|
||||
projectPath,
|
||||
promptId,
|
||||
category,
|
||||
suggestionCount
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
suggestions,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Failed to generate suggestions');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,10 @@
|
||||
* Common utilities for MCP routes
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('MCP');
|
||||
|
||||
/**
|
||||
* Extract error message from unknown error
|
||||
*/
|
||||
@@ -16,5 +20,5 @@ export function getErrorMessage(error: unknown): string {
|
||||
* Log error with prefix
|
||||
*/
|
||||
export function logError(error: unknown, message: string): void {
|
||||
console.error(`[MCP] ${message}:`, error);
|
||||
logger.error(`${message}:`, error);
|
||||
}
|
||||
|
||||
@@ -1,61 +1,16 @@
|
||||
/**
|
||||
* GET /available endpoint - Get available models
|
||||
* GET /available endpoint - Get available models from all providers
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { ProviderFactory } from '../../../providers/provider-factory.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
interface ModelDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
contextWindow: number;
|
||||
maxOutputTokens: number;
|
||||
supportsVision: boolean;
|
||||
supportsTools: boolean;
|
||||
}
|
||||
|
||||
export function createAvailableHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const models: ModelDefinition[] = [
|
||||
{
|
||||
id: 'claude-opus-4-5-20251101',
|
||||
name: 'Claude Opus 4.5',
|
||||
provider: 'anthropic',
|
||||
contextWindow: 200000,
|
||||
maxOutputTokens: 16384,
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
},
|
||||
{
|
||||
id: 'claude-sonnet-4-20250514',
|
||||
name: 'Claude Sonnet 4',
|
||||
provider: 'anthropic',
|
||||
contextWindow: 200000,
|
||||
maxOutputTokens: 16384,
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
},
|
||||
{
|
||||
id: 'claude-3-5-sonnet-20241022',
|
||||
name: 'Claude 3.5 Sonnet',
|
||||
provider: 'anthropic',
|
||||
contextWindow: 200000,
|
||||
maxOutputTokens: 8192,
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
},
|
||||
{
|
||||
id: 'claude-3-5-haiku-20241022',
|
||||
name: 'Claude 3.5 Haiku',
|
||||
provider: 'anthropic',
|
||||
contextWindow: 200000,
|
||||
maxOutputTokens: 8192,
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
},
|
||||
];
|
||||
// Get all models from all registered providers (Claude + Cursor)
|
||||
const models = ProviderFactory.getAllAvailableModels();
|
||||
|
||||
res.json({ success: true, models });
|
||||
} catch (error) {
|
||||
|
||||
@@ -17,6 +17,13 @@ export function createProvidersHandler() {
|
||||
available: statuses.claude?.installed || false,
|
||||
hasApiKey: !!process.env.ANTHROPIC_API_KEY,
|
||||
},
|
||||
cursor: {
|
||||
available: statuses.cursor?.installed || false,
|
||||
version: statuses.cursor?.version,
|
||||
path: statuses.cursor?.path,
|
||||
method: statuses.cursor?.method,
|
||||
authenticated: statuses.cursor?.authenticated,
|
||||
},
|
||||
};
|
||||
|
||||
res.json({ success: true, providers });
|
||||
|
||||
@@ -12,6 +12,17 @@ import { createApiKeysHandler } from './routes/api-keys.js';
|
||||
import { createPlatformHandler } from './routes/platform.js';
|
||||
import { createVerifyClaudeAuthHandler } from './routes/verify-claude-auth.js';
|
||||
import { createGhStatusHandler } from './routes/gh-status.js';
|
||||
import { createCursorStatusHandler } from './routes/cursor-status.js';
|
||||
import {
|
||||
createGetCursorConfigHandler,
|
||||
createSetCursorDefaultModelHandler,
|
||||
createSetCursorModelsHandler,
|
||||
createGetCursorPermissionsHandler,
|
||||
createApplyPermissionProfileHandler,
|
||||
createSetCustomPermissionsHandler,
|
||||
createDeleteProjectPermissionsHandler,
|
||||
createGetExampleConfigHandler,
|
||||
} from './routes/cursor-config.js';
|
||||
|
||||
export function createSetupRoutes(): Router {
|
||||
const router = Router();
|
||||
@@ -26,5 +37,18 @@ export function createSetupRoutes(): Router {
|
||||
router.post('/verify-claude-auth', createVerifyClaudeAuthHandler());
|
||||
router.get('/gh-status', createGhStatusHandler());
|
||||
|
||||
// Cursor CLI routes
|
||||
router.get('/cursor-status', createCursorStatusHandler());
|
||||
router.get('/cursor-config', createGetCursorConfigHandler());
|
||||
router.post('/cursor-config/default-model', createSetCursorDefaultModelHandler());
|
||||
router.post('/cursor-config/models', createSetCursorModelsHandler());
|
||||
|
||||
// Cursor CLI Permissions routes
|
||||
router.get('/cursor-permissions', createGetCursorPermissionsHandler());
|
||||
router.post('/cursor-permissions/profile', createApplyPermissionProfileHandler());
|
||||
router.post('/cursor-permissions/custom', createSetCustomPermissionsHandler());
|
||||
router.delete('/cursor-permissions', createDeleteProjectPermissionsHandler());
|
||||
router.get('/cursor-permissions/example', createGetExampleConfigHandler());
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
411
apps/server/src/routes/setup/routes/cursor-config.ts
Normal file
411
apps/server/src/routes/setup/routes/cursor-config.ts
Normal file
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* Cursor CLI configuration routes
|
||||
*
|
||||
* Provides endpoints for managing Cursor CLI configuration:
|
||||
* - GET /api/setup/cursor-config - Get current configuration
|
||||
* - POST /api/setup/cursor-config/default-model - Set default model
|
||||
* - POST /api/setup/cursor-config/models - Set enabled models
|
||||
*
|
||||
* Cursor CLI Permissions endpoints:
|
||||
* - GET /api/setup/cursor-permissions - Get permissions config
|
||||
* - POST /api/setup/cursor-permissions/profile - Apply a permission profile
|
||||
* - POST /api/setup/cursor-permissions/custom - Set custom permissions
|
||||
* - DELETE /api/setup/cursor-permissions - Delete project permissions (use global)
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import path from 'path';
|
||||
import { CursorConfigManager } from '../../../providers/cursor-config-manager.js';
|
||||
import {
|
||||
CURSOR_MODEL_MAP,
|
||||
CURSOR_PERMISSION_PROFILES,
|
||||
type CursorModelId,
|
||||
type CursorPermissionProfile,
|
||||
type CursorCliPermissions,
|
||||
} from '@automaker/types';
|
||||
import {
|
||||
readGlobalConfig,
|
||||
readProjectConfig,
|
||||
getEffectivePermissions,
|
||||
applyProfileToProject,
|
||||
applyProfileGlobally,
|
||||
writeProjectConfig,
|
||||
deleteProjectConfig,
|
||||
detectProfile,
|
||||
hasProjectConfig,
|
||||
getAvailableProfiles,
|
||||
generateExampleConfig,
|
||||
} from '../../../services/cursor-config-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
/**
|
||||
* Validate that a project path is safe (no path traversal)
|
||||
* @throws Error if path contains traversal sequences
|
||||
*/
|
||||
function validateProjectPath(projectPath: string): void {
|
||||
// Resolve to absolute path and check for traversal
|
||||
const resolved = path.resolve(projectPath);
|
||||
const normalized = path.normalize(projectPath);
|
||||
|
||||
// Check for obvious traversal attempts
|
||||
if (normalized.includes('..') || projectPath.includes('..')) {
|
||||
throw new Error('Invalid project path: path traversal not allowed');
|
||||
}
|
||||
|
||||
// Ensure the resolved path doesn't escape intended boundaries
|
||||
// by checking if it starts with the normalized path components
|
||||
if (!resolved.startsWith(path.resolve(normalized))) {
|
||||
throw new Error('Invalid project path: path traversal detected');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates handler for GET /api/setup/cursor-config
|
||||
* Returns current Cursor configuration and available models
|
||||
*/
|
||||
export function createGetCursorConfigHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const projectPath = req.query.projectPath as string;
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'projectPath query parameter is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate path to prevent traversal attacks
|
||||
validateProjectPath(projectPath);
|
||||
|
||||
const configManager = new CursorConfigManager(projectPath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
config: configManager.getConfig(),
|
||||
availableModels: Object.values(CURSOR_MODEL_MAP),
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Get Cursor config failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates handler for POST /api/setup/cursor-config/default-model
|
||||
* Sets the default Cursor model
|
||||
*/
|
||||
export function createSetCursorDefaultModelHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { model, projectPath } = req.body;
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'projectPath is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate path to prevent traversal attacks
|
||||
validateProjectPath(projectPath);
|
||||
|
||||
if (!model || !(model in CURSOR_MODEL_MAP)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `Invalid model ID. Valid models: ${Object.keys(CURSOR_MODEL_MAP).join(', ')}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const configManager = new CursorConfigManager(projectPath);
|
||||
configManager.setDefaultModel(model as CursorModelId);
|
||||
|
||||
res.json({ success: true, model });
|
||||
} catch (error) {
|
||||
logError(error, 'Set Cursor default model failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates handler for POST /api/setup/cursor-config/models
|
||||
* Sets the enabled Cursor models list
|
||||
*/
|
||||
export function createSetCursorModelsHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { models, projectPath } = req.body;
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'projectPath is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate path to prevent traversal attacks
|
||||
validateProjectPath(projectPath);
|
||||
|
||||
if (!Array.isArray(models)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Models must be an array',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter to valid models only
|
||||
const validModels = models.filter((m): m is CursorModelId => m in CURSOR_MODEL_MAP);
|
||||
|
||||
if (validModels.length === 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'No valid models provided',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const configManager = new CursorConfigManager(projectPath);
|
||||
configManager.setEnabledModels(validModels);
|
||||
|
||||
res.json({ success: true, models: validModels });
|
||||
} catch (error) {
|
||||
logError(error, 'Set Cursor models failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Cursor CLI Permissions Handlers
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Creates handler for GET /api/setup/cursor-permissions
|
||||
* Returns current permissions configuration and available profiles
|
||||
*/
|
||||
export function createGetCursorPermissionsHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const projectPath = req.query.projectPath as string | undefined;
|
||||
|
||||
// Validate path if provided
|
||||
if (projectPath) {
|
||||
validateProjectPath(projectPath);
|
||||
}
|
||||
|
||||
// Get global config
|
||||
const globalConfig = await readGlobalConfig();
|
||||
|
||||
// Get project config if path provided
|
||||
const projectConfig = projectPath ? await readProjectConfig(projectPath) : null;
|
||||
|
||||
// Get effective permissions
|
||||
const effectivePermissions = await getEffectivePermissions(projectPath);
|
||||
|
||||
// Detect which profile is active
|
||||
const activeProfile = detectProfile(effectivePermissions);
|
||||
|
||||
// Check if project has its own config
|
||||
const hasProject = projectPath ? await hasProjectConfig(projectPath) : false;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
globalPermissions: globalConfig?.permissions || null,
|
||||
projectPermissions: projectConfig?.permissions || null,
|
||||
effectivePermissions,
|
||||
activeProfile,
|
||||
hasProjectConfig: hasProject,
|
||||
availableProfiles: getAvailableProfiles(),
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Get Cursor permissions failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates handler for POST /api/setup/cursor-permissions/profile
|
||||
* Applies a predefined permission profile
|
||||
*/
|
||||
export function createApplyPermissionProfileHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { profileId, projectPath, scope } = req.body as {
|
||||
profileId: CursorPermissionProfile;
|
||||
projectPath?: string;
|
||||
scope: 'global' | 'project';
|
||||
};
|
||||
|
||||
// Validate profile
|
||||
const validProfiles = CURSOR_PERMISSION_PROFILES.map((p) => p.id);
|
||||
if (!validProfiles.includes(profileId)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `Invalid profile. Valid profiles: ${validProfiles.join(', ')}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (scope === 'project') {
|
||||
if (!projectPath) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'projectPath is required for project scope',
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Validate path to prevent traversal attacks
|
||||
validateProjectPath(projectPath);
|
||||
await applyProfileToProject(projectPath, profileId);
|
||||
} else {
|
||||
await applyProfileGlobally(profileId);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Applied "${profileId}" profile to ${scope}`,
|
||||
scope,
|
||||
profileId,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Apply Cursor permission profile failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates handler for POST /api/setup/cursor-permissions/custom
|
||||
* Sets custom permissions for a project
|
||||
*/
|
||||
export function createSetCustomPermissionsHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, permissions } = req.body as {
|
||||
projectPath: string;
|
||||
permissions: CursorCliPermissions;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'projectPath is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate path to prevent traversal attacks
|
||||
validateProjectPath(projectPath);
|
||||
|
||||
if (!permissions || !Array.isArray(permissions.allow) || !Array.isArray(permissions.deny)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'permissions must have allow and deny arrays',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await writeProjectConfig(projectPath, {
|
||||
version: 1,
|
||||
permissions,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Custom permissions saved',
|
||||
permissions,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Set custom Cursor permissions failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates handler for DELETE /api/setup/cursor-permissions
|
||||
* Deletes project-level permissions (falls back to global)
|
||||
*/
|
||||
export function createDeleteProjectPermissionsHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const projectPath = req.query.projectPath as string;
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'projectPath query parameter is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate path to prevent traversal attacks
|
||||
validateProjectPath(projectPath);
|
||||
|
||||
await deleteProjectConfig(projectPath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Project permissions deleted, using global config',
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Delete Cursor project permissions failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates handler for GET /api/setup/cursor-permissions/example
|
||||
* Returns an example config file for a profile
|
||||
*/
|
||||
export function createGetExampleConfigHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const profileId = (req.query.profileId as CursorPermissionProfile) || 'development';
|
||||
|
||||
const exampleConfig = generateExampleConfig(profileId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
profileId,
|
||||
config: exampleConfig,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Get example Cursor config failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
50
apps/server/src/routes/setup/routes/cursor-status.ts
Normal file
50
apps/server/src/routes/setup/routes/cursor-status.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* GET /cursor-status endpoint - Get Cursor CLI installation and auth status
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { CursorProvider } from '../../../providers/cursor-provider.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
/**
|
||||
* Creates handler for GET /api/setup/cursor-status
|
||||
* Returns Cursor CLI installation and authentication status
|
||||
*/
|
||||
export function createCursorStatusHandler() {
|
||||
const installCommand = 'curl https://cursor.com/install -fsS | bash';
|
||||
const loginCommand = 'cursor-agent login';
|
||||
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const provider = new CursorProvider();
|
||||
|
||||
const [installed, version, auth] = await Promise.all([
|
||||
provider.isInstalled(),
|
||||
provider.getVersion(),
|
||||
provider.checkAuth(),
|
||||
]);
|
||||
|
||||
// Get CLI path from provider using public accessor
|
||||
const cliPath = installed ? provider.getCliPath() : null;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
installed,
|
||||
version: version || null,
|
||||
path: cliPath,
|
||||
auth: {
|
||||
authenticated: auth.authenticated,
|
||||
method: auth.method,
|
||||
},
|
||||
installCommand,
|
||||
loginCommand,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Get Cursor status failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -71,10 +71,15 @@ function containsAuthError(text: string): boolean {
|
||||
export function createVerifyClaudeAuthHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Get the auth method from the request body
|
||||
const { authMethod } = req.body as { authMethod?: 'cli' | 'api_key' };
|
||||
// Get the auth method and optional API key from the request body
|
||||
const { authMethod, apiKey } = req.body as {
|
||||
authMethod?: 'cli' | 'api_key';
|
||||
apiKey?: string;
|
||||
};
|
||||
|
||||
logger.info(`[Setup] Verifying Claude authentication using method: ${authMethod || 'auto'}`);
|
||||
logger.info(
|
||||
`[Setup] Verifying Claude authentication using method: ${authMethod || 'auto'}${apiKey ? ' (with provided key)' : ''}`
|
||||
);
|
||||
|
||||
// Create an AbortController with a 30-second timeout
|
||||
const abortController = new AbortController();
|
||||
@@ -94,14 +99,17 @@ export function createVerifyClaudeAuthHandler() {
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
logger.info('[Setup] Cleared API key environment for CLI verification');
|
||||
} else if (authMethod === 'api_key') {
|
||||
// For API key verification, ensure we're using the stored API key
|
||||
const storedApiKey = getApiKey('anthropic');
|
||||
if (storedApiKey) {
|
||||
process.env.ANTHROPIC_API_KEY = storedApiKey;
|
||||
logger.info('[Setup] Using stored API key for verification');
|
||||
// For API key verification, use provided key, stored key, or env var (in order of priority)
|
||||
if (apiKey) {
|
||||
// Use the provided API key (allows testing unsaved keys)
|
||||
process.env.ANTHROPIC_API_KEY = apiKey;
|
||||
logger.info('[Setup] Using provided API key for verification');
|
||||
} else {
|
||||
// Check env var
|
||||
if (!process.env.ANTHROPIC_API_KEY) {
|
||||
const storedApiKey = getApiKey('anthropic');
|
||||
if (storedApiKey) {
|
||||
process.env.ANTHROPIC_API_KEY = storedApiKey;
|
||||
logger.info('[Setup] Using stored API key for verification');
|
||||
} else if (!process.env.ANTHROPIC_API_KEY) {
|
||||
res.json({
|
||||
success: true,
|
||||
authenticated: false,
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
/**
|
||||
* Business logic for generating suggestions
|
||||
*
|
||||
* Model is configurable via phaseModels.suggestionsModel in settings
|
||||
* (AI Suggestions in the UI). Supports both Claude and Cursor models.
|
||||
*/
|
||||
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { DEFAULT_PHASE_MODELS, isCursorModel, type ThinkingLevel } from '@automaker/types';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { createSuggestionsOptions } from '../../lib/sdk-options.js';
|
||||
import { extractJsonWithArray } from '../../lib/json-extractor.js';
|
||||
import { ProviderFactory } from '../../providers/provider-factory.js';
|
||||
import { FeatureLoader } from '../../services/feature-loader.js';
|
||||
import { getAppSpecPath } from '@automaker/platform';
|
||||
import * as secureFs from '../../lib/secure-fs.js';
|
||||
@@ -128,7 +135,9 @@ export async function generateSuggestions(
|
||||
suggestionType: string,
|
||||
events: EventEmitter,
|
||||
abortController: AbortController,
|
||||
settingsService?: SettingsService
|
||||
settingsService?: SettingsService,
|
||||
modelOverride?: string,
|
||||
thinkingLevelOverride?: ThinkingLevel
|
||||
): Promise<void> {
|
||||
const typePrompts: Record<string, string> = {
|
||||
features: 'Analyze this project and suggest new features that would add value.',
|
||||
@@ -164,55 +173,144 @@ The response will be automatically formatted as structured JSON.`;
|
||||
'[Suggestions]'
|
||||
);
|
||||
|
||||
const options = createSuggestionsOptions({
|
||||
cwd: projectPath,
|
||||
abortController,
|
||||
autoLoadClaudeMd,
|
||||
outputFormat: {
|
||||
type: 'json_schema',
|
||||
schema: suggestionsSchema,
|
||||
},
|
||||
});
|
||||
// Get model from phase settings (AI Suggestions = suggestionsModel)
|
||||
// Use override if provided, otherwise fall back to settings
|
||||
const settings = await settingsService?.getGlobalSettings();
|
||||
let model: string;
|
||||
let thinkingLevel: ThinkingLevel | undefined;
|
||||
|
||||
if (modelOverride) {
|
||||
// Use explicit override - resolve the model string
|
||||
const resolved = resolvePhaseModel({
|
||||
model: modelOverride,
|
||||
thinkingLevel: thinkingLevelOverride,
|
||||
});
|
||||
model = resolved.model;
|
||||
thinkingLevel = resolved.thinkingLevel;
|
||||
} else {
|
||||
// Use settings-based model
|
||||
const phaseModelEntry =
|
||||
settings?.phaseModels?.suggestionsModel || DEFAULT_PHASE_MODELS.suggestionsModel;
|
||||
const resolved = resolvePhaseModel(phaseModelEntry);
|
||||
model = resolved.model;
|
||||
thinkingLevel = resolved.thinkingLevel;
|
||||
}
|
||||
|
||||
logger.info('[Suggestions] Using model:', model);
|
||||
|
||||
const stream = query({ prompt, options });
|
||||
let responseText = '';
|
||||
let structuredOutput: { suggestions: Array<Record<string, unknown>> } | null = null;
|
||||
|
||||
for await (const msg of stream) {
|
||||
if (msg.type === 'assistant' && msg.message.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === 'text') {
|
||||
responseText += block.text;
|
||||
events.emit('suggestions:event', {
|
||||
type: 'suggestions_progress',
|
||||
content: block.text,
|
||||
});
|
||||
} else if (block.type === 'tool_use') {
|
||||
events.emit('suggestions:event', {
|
||||
type: 'suggestions_tool',
|
||||
tool: block.name,
|
||||
input: block.input,
|
||||
});
|
||||
// Route to appropriate provider based on model type
|
||||
if (isCursorModel(model)) {
|
||||
// Use Cursor provider for Cursor models
|
||||
logger.info('[Suggestions] Using Cursor provider');
|
||||
|
||||
const provider = ProviderFactory.getProviderForModel(model);
|
||||
|
||||
// For Cursor, include the JSON schema in the prompt with clear instructions
|
||||
const cursorPrompt = `${prompt}
|
||||
|
||||
CRITICAL INSTRUCTIONS:
|
||||
1. DO NOT write any files. Return the JSON in your response only.
|
||||
2. After analyzing the project, respond with ONLY a JSON object - no explanations, no markdown, just raw JSON.
|
||||
3. The JSON must match this exact schema:
|
||||
|
||||
${JSON.stringify(suggestionsSchema, null, 2)}
|
||||
|
||||
Your entire response should be valid JSON starting with { and ending with }. No text before or after.`;
|
||||
|
||||
for await (const msg of provider.executeQuery({
|
||||
prompt: cursorPrompt,
|
||||
model,
|
||||
cwd: projectPath,
|
||||
maxTurns: 250,
|
||||
allowedTools: ['Read', 'Glob', 'Grep'],
|
||||
abortController,
|
||||
readOnly: true, // Suggestions only reads code, doesn't write
|
||||
})) {
|
||||
if (msg.type === 'assistant' && msg.message?.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
responseText += block.text;
|
||||
events.emit('suggestions:event', {
|
||||
type: 'suggestions_progress',
|
||||
content: block.text,
|
||||
});
|
||||
} else if (block.type === 'tool_use') {
|
||||
events.emit('suggestions:event', {
|
||||
type: 'suggestions_tool',
|
||||
tool: block.name,
|
||||
input: block.input,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
|
||||
// Use result if it's a final accumulated message (from Cursor provider)
|
||||
logger.info('[Suggestions] Received result from Cursor, length:', msg.result.length);
|
||||
logger.info('[Suggestions] Previous responseText length:', responseText.length);
|
||||
if (msg.result.length > responseText.length) {
|
||||
logger.info('[Suggestions] Using Cursor result (longer than accumulated text)');
|
||||
responseText = msg.result;
|
||||
} else {
|
||||
logger.info('[Suggestions] Keeping accumulated text (longer than Cursor result)');
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'result' && msg.subtype === 'success') {
|
||||
// Check for structured output
|
||||
const resultMsg = msg as any;
|
||||
if (resultMsg.structured_output) {
|
||||
structuredOutput = resultMsg.structured_output as {
|
||||
suggestions: Array<Record<string, unknown>>;
|
||||
};
|
||||
logger.debug('Received structured output:', structuredOutput);
|
||||
}
|
||||
} else if (msg.type === 'result') {
|
||||
const resultMsg = msg as any;
|
||||
if (resultMsg.subtype === 'error_max_structured_output_retries') {
|
||||
logger.error('Failed to produce valid structured output after retries');
|
||||
throw new Error('Could not produce valid suggestions output');
|
||||
} else if (resultMsg.subtype === 'error_max_turns') {
|
||||
logger.error('Hit max turns limit before completing suggestions generation');
|
||||
logger.warn(`Response text length: ${responseText.length} chars`);
|
||||
// Still try to parse what we have
|
||||
}
|
||||
} else {
|
||||
// Use Claude SDK for Claude models
|
||||
logger.info('[Suggestions] Using Claude SDK');
|
||||
|
||||
const options = createSuggestionsOptions({
|
||||
cwd: projectPath,
|
||||
abortController,
|
||||
autoLoadClaudeMd,
|
||||
model, // Pass the model from settings
|
||||
thinkingLevel, // Pass thinking level for extended thinking
|
||||
outputFormat: {
|
||||
type: 'json_schema',
|
||||
schema: suggestionsSchema,
|
||||
},
|
||||
});
|
||||
|
||||
const stream = query({ prompt, options });
|
||||
|
||||
for await (const msg of stream) {
|
||||
if (msg.type === 'assistant' && msg.message.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === 'text') {
|
||||
responseText += block.text;
|
||||
events.emit('suggestions:event', {
|
||||
type: 'suggestions_progress',
|
||||
content: block.text,
|
||||
});
|
||||
} else if (block.type === 'tool_use') {
|
||||
events.emit('suggestions:event', {
|
||||
type: 'suggestions_tool',
|
||||
tool: block.name,
|
||||
input: block.input,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'result' && msg.subtype === 'success') {
|
||||
// Check for structured output
|
||||
const resultMsg = msg as any;
|
||||
if (resultMsg.structured_output) {
|
||||
structuredOutput = resultMsg.structured_output as {
|
||||
suggestions: Array<Record<string, unknown>>;
|
||||
};
|
||||
logger.debug('Received structured output:', structuredOutput);
|
||||
}
|
||||
} else if (msg.type === 'result') {
|
||||
const resultMsg = msg as any;
|
||||
if (resultMsg.subtype === 'error_max_structured_output_retries') {
|
||||
logger.error('Failed to produce valid structured output after retries');
|
||||
throw new Error('Could not produce valid suggestions output');
|
||||
} else if (resultMsg.subtype === 'error_max_turns') {
|
||||
logger.error('Hit max turns limit before completing suggestions generation');
|
||||
logger.warn(`Response text length: ${responseText.length} chars`);
|
||||
// Still try to parse what we have
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -229,11 +327,14 @@ The response will be automatically formatted as structured JSON.`;
|
||||
})),
|
||||
});
|
||||
} else {
|
||||
// Fallback: try to parse from text (for backwards compatibility)
|
||||
// Fallback: try to parse from text using shared extraction utility
|
||||
logger.warn('No structured output received, attempting to parse from text');
|
||||
const jsonMatch = responseText.match(/\{[\s\S]*"suggestions"[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
const parsed = JSON.parse(jsonMatch[0]);
|
||||
const parsed = extractJsonWithArray<{ suggestions: Array<Record<string, unknown>> }>(
|
||||
responseText,
|
||||
'suggestions',
|
||||
{ logger }
|
||||
);
|
||||
if (parsed && parsed.suggestions) {
|
||||
events.emit('suggestions:event', {
|
||||
type: 'suggestions_complete',
|
||||
suggestions: parsed.suggestions.map((s: Record<string, unknown>, i: number) => ({
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import type { EventEmitter } from '../../../lib/events.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { ThinkingLevel } from '@automaker/types';
|
||||
import { getSuggestionsStatus, setRunningState, getErrorMessage, logError } from '../common.js';
|
||||
import { generateSuggestions } from '../generate-suggestions.js';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
@@ -14,9 +15,16 @@ const logger = createLogger('Suggestions');
|
||||
export function createGenerateHandler(events: EventEmitter, settingsService?: SettingsService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, suggestionType = 'features' } = req.body as {
|
||||
const {
|
||||
projectPath,
|
||||
suggestionType = 'features',
|
||||
model,
|
||||
thinkingLevel,
|
||||
} = req.body as {
|
||||
projectPath: string;
|
||||
suggestionType?: string;
|
||||
model?: string;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
@@ -38,7 +46,15 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se
|
||||
setRunningState(true, abortController);
|
||||
|
||||
// Start generation in background
|
||||
generateSuggestions(projectPath, suggestionType, events, abortController, settingsService)
|
||||
generateSuggestions(
|
||||
projectPath,
|
||||
suggestionType,
|
||||
events,
|
||||
abortController,
|
||||
settingsService,
|
||||
model,
|
||||
thinkingLevel
|
||||
)
|
||||
.catch((error) => {
|
||||
logError(error, 'Generate suggestions failed (background)');
|
||||
events.emit('suggestions:event', {
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import path from 'path';
|
||||
import { getBranchTrackingPath, ensureAutomakerDir } from '@automaker/platform';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('BranchTracking');
|
||||
|
||||
export interface TrackedBranch {
|
||||
name: string;
|
||||
@@ -32,7 +35,7 @@ export async function getTrackedBranches(projectPath: string): Promise<TrackedBr
|
||||
if (error.code === 'ENOENT') {
|
||||
return [];
|
||||
}
|
||||
console.warn('[branch-tracking] Failed to read tracked branches:', error);
|
||||
logger.warn('Failed to read tracked branches:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -65,7 +68,7 @@ export async function trackBranch(projectPath: string, branchName: string): Prom
|
||||
});
|
||||
|
||||
await saveTrackedBranches(projectPath, branches);
|
||||
console.log(`[branch-tracking] Now tracking branch: ${branchName}`);
|
||||
logger.info(`Now tracking branch: ${branchName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,7 +80,7 @@ export async function untrackBranch(projectPath: string, branchName: string): Pr
|
||||
|
||||
if (filtered.length !== branches.length) {
|
||||
await saveTrackedBranches(projectPath, filtered);
|
||||
console.log(`[branch-tracking] Stopped tracking branch: ${branchName}`);
|
||||
logger.info(`Stopped tracking branch: ${branchName}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ export function createCheckoutBranchHandler() {
|
||||
}
|
||||
|
||||
// Get current branch for reference
|
||||
const { stdout: currentBranchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||
const { stdout: currentBranchOutput } = await execAsync('git symbolic-ref --short HEAD', {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
const currentBranch = currentBranchOutput.trim();
|
||||
|
||||
@@ -59,7 +59,7 @@ export function createCommitHandler() {
|
||||
const commitHash = hashOutput.trim().substring(0, 8);
|
||||
|
||||
// Get branch name
|
||||
const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||
const { stdout: branchOutput } = await execAsync('git symbolic-ref --short HEAD', {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
const branchName = branchOutput.trim();
|
||||
|
||||
@@ -12,6 +12,9 @@ import {
|
||||
isGhCliAvailable,
|
||||
} from '../common.js';
|
||||
import { updateWorktreePRInfo } from '../../../lib/worktree-metadata.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('CreatePR');
|
||||
|
||||
export function createCreatePRHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -40,7 +43,7 @@ export function createCreatePRHandler() {
|
||||
const effectiveProjectPath = projectPath || worktreePath;
|
||||
|
||||
// Get current branch name
|
||||
const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||
const { stdout: branchOutput } = await execAsync('git symbolic-ref --short HEAD', {
|
||||
cwd: worktreePath,
|
||||
env: execEnv,
|
||||
});
|
||||
@@ -56,15 +59,15 @@ export function createCreatePRHandler() {
|
||||
}
|
||||
|
||||
// Check for uncommitted changes
|
||||
console.log(`[CreatePR] Checking for uncommitted changes in: ${worktreePath}`);
|
||||
logger.debug(`Checking for uncommitted changes in: ${worktreePath}`);
|
||||
const { stdout: status } = await execAsync('git status --porcelain', {
|
||||
cwd: worktreePath,
|
||||
env: execEnv,
|
||||
});
|
||||
const hasChanges = status.trim().length > 0;
|
||||
console.log(`[CreatePR] Has uncommitted changes: ${hasChanges}`);
|
||||
logger.debug(`Has uncommitted changes: ${hasChanges}`);
|
||||
if (hasChanges) {
|
||||
console.log(`[CreatePR] Changed files:\n${status}`);
|
||||
logger.debug(`Changed files:\n${status}`);
|
||||
}
|
||||
|
||||
// If there are changes, commit them
|
||||
@@ -72,15 +75,15 @@ export function createCreatePRHandler() {
|
||||
let commitError: string | null = null;
|
||||
if (hasChanges) {
|
||||
const message = commitMessage || `Changes from ${branchName}`;
|
||||
console.log(`[CreatePR] Committing changes with message: ${message}`);
|
||||
logger.debug(`Committing changes with message: ${message}`);
|
||||
|
||||
try {
|
||||
// Stage all changes
|
||||
console.log(`[CreatePR] Running: git add -A`);
|
||||
logger.debug(`Running: git add -A`);
|
||||
await execAsync('git add -A', { cwd: worktreePath, env: execEnv });
|
||||
|
||||
// Create commit
|
||||
console.log(`[CreatePR] Running: git commit`);
|
||||
logger.debug(`Running: git commit`);
|
||||
await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, {
|
||||
cwd: worktreePath,
|
||||
env: execEnv,
|
||||
@@ -92,11 +95,11 @@ export function createCreatePRHandler() {
|
||||
env: execEnv,
|
||||
});
|
||||
commitHash = hashOutput.trim().substring(0, 8);
|
||||
console.log(`[CreatePR] Commit successful: ${commitHash}`);
|
||||
logger.info(`Commit successful: ${commitHash}`);
|
||||
} catch (commitErr: unknown) {
|
||||
const err = commitErr as { stderr?: string; message?: string };
|
||||
commitError = err.stderr || err.message || 'Commit failed';
|
||||
console.error(`[CreatePR] Commit failed: ${commitError}`);
|
||||
logger.error(`Commit failed: ${commitError}`);
|
||||
|
||||
// Return error immediately - don't proceed with push/PR if commit fails
|
||||
res.status(500).json({
|
||||
@@ -126,7 +129,7 @@ export function createCreatePRHandler() {
|
||||
// Capture push error for reporting
|
||||
const err = error2 as { stderr?: string; message?: string };
|
||||
pushError = err.stderr || err.message || 'Push failed';
|
||||
console.error('[CreatePR] Push failed:', pushError);
|
||||
logger.error('Push failed:', pushError);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,26 +249,22 @@ export function createCreatePRHandler() {
|
||||
const headRef = upstreamRepo && originOwner ? `${originOwner}:${branchName}` : branchName;
|
||||
const repoArg = upstreamRepo ? ` --repo "${upstreamRepo}"` : '';
|
||||
|
||||
console.log(
|
||||
`[CreatePR] Checking for existing PR for branch: ${branchName} (headRef: ${headRef})`
|
||||
);
|
||||
logger.debug(`Checking for existing PR for branch: ${branchName} (headRef: ${headRef})`);
|
||||
try {
|
||||
const listCmd = `gh pr list${repoArg} --head "${headRef}" --json number,title,url,state --limit 1`;
|
||||
console.log(`[CreatePR] Running: ${listCmd}`);
|
||||
logger.debug(`Running: ${listCmd}`);
|
||||
const { stdout: existingPrOutput } = await execAsync(listCmd, {
|
||||
cwd: worktreePath,
|
||||
env: execEnv,
|
||||
});
|
||||
console.log(`[CreatePR] gh pr list output: ${existingPrOutput}`);
|
||||
logger.debug(`gh pr list output: ${existingPrOutput}`);
|
||||
|
||||
const existingPrs = JSON.parse(existingPrOutput);
|
||||
|
||||
if (Array.isArray(existingPrs) && existingPrs.length > 0) {
|
||||
const existingPr = existingPrs[0];
|
||||
// PR already exists - use it and store metadata
|
||||
console.log(
|
||||
`[CreatePR] PR already exists for branch ${branchName}: PR #${existingPr.number}`
|
||||
);
|
||||
logger.info(`PR already exists for branch ${branchName}: PR #${existingPr.number}`);
|
||||
prUrl = existingPr.url;
|
||||
prNumber = existingPr.number;
|
||||
prAlreadyExisted = true;
|
||||
@@ -278,15 +277,15 @@ export function createCreatePRHandler() {
|
||||
state: existingPr.state || 'open',
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
console.log(
|
||||
`[CreatePR] Stored existing PR info for branch ${branchName}: PR #${existingPr.number}`
|
||||
logger.debug(
|
||||
`Stored existing PR info for branch ${branchName}: PR #${existingPr.number}`
|
||||
);
|
||||
} else {
|
||||
console.log(`[CreatePR] No existing PR found for branch ${branchName}`);
|
||||
logger.debug(`No existing PR found for branch ${branchName}`);
|
||||
}
|
||||
} catch (listError) {
|
||||
// gh pr list failed - log but continue to try creating
|
||||
console.log(`[CreatePR] gh pr list failed (this is ok, will try to create):`, listError);
|
||||
logger.debug(`gh pr list failed (this is ok, will try to create):`, listError);
|
||||
}
|
||||
|
||||
// Only create a new PR if one doesn't already exist
|
||||
@@ -307,13 +306,13 @@ export function createCreatePRHandler() {
|
||||
prCmd += ` --title "${title.replace(/"/g, '\\"')}" --body "${body.replace(/"/g, '\\"')}" ${draftFlag}`;
|
||||
prCmd = prCmd.trim();
|
||||
|
||||
console.log(`[CreatePR] Creating PR with command: ${prCmd}`);
|
||||
logger.debug(`Creating PR with command: ${prCmd}`);
|
||||
const { stdout: prOutput } = await execAsync(prCmd, {
|
||||
cwd: worktreePath,
|
||||
env: execEnv,
|
||||
});
|
||||
prUrl = prOutput.trim();
|
||||
console.log(`[CreatePR] PR created: ${prUrl}`);
|
||||
logger.info(`PR created: ${prUrl}`);
|
||||
|
||||
// Extract PR number and store metadata for newly created PR
|
||||
if (prUrl) {
|
||||
@@ -329,11 +328,9 @@ export function createCreatePRHandler() {
|
||||
state: draft ? 'draft' : 'open',
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
console.log(
|
||||
`[CreatePR] Stored PR info for branch ${branchName}: PR #${prNumber}`
|
||||
);
|
||||
logger.debug(`Stored PR info for branch ${branchName}: PR #${prNumber}`);
|
||||
} catch (metadataError) {
|
||||
console.error('[CreatePR] Failed to store PR metadata:', metadataError);
|
||||
logger.error('Failed to store PR metadata:', metadataError);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -341,11 +338,11 @@ export function createCreatePRHandler() {
|
||||
// gh CLI failed - check if it's "already exists" error and try to fetch the PR
|
||||
const err = ghError as { stderr?: string; message?: string };
|
||||
const errorMessage = err.stderr || err.message || 'PR creation failed';
|
||||
console.log(`[CreatePR] gh pr create failed: ${errorMessage}`);
|
||||
logger.debug(`gh pr create failed: ${errorMessage}`);
|
||||
|
||||
// If error indicates PR already exists, try to fetch it
|
||||
if (errorMessage.toLowerCase().includes('already exists')) {
|
||||
console.log(`[CreatePR] PR already exists error - trying to fetch existing PR`);
|
||||
logger.debug(`PR already exists error - trying to fetch existing PR`);
|
||||
try {
|
||||
const { stdout: viewOutput } = await execAsync(
|
||||
`gh pr view --json number,title,url,state`,
|
||||
@@ -364,10 +361,10 @@ export function createCreatePRHandler() {
|
||||
state: existingPr.state || 'open',
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
console.log(`[CreatePR] Fetched and stored existing PR: #${existingPr.number}`);
|
||||
logger.debug(`Fetched and stored existing PR: #${existingPr.number}`);
|
||||
}
|
||||
} catch (viewError) {
|
||||
console.error('[CreatePR] Failed to fetch existing PR:', viewError);
|
||||
logger.error('Failed to fetch existing PR:', viewError);
|
||||
prError = errorMessage;
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -20,6 +20,9 @@ import {
|
||||
ensureInitialCommit,
|
||||
} from '../common.js';
|
||||
import { trackBranch } from './branch-tracking.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('Worktree');
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
@@ -114,8 +117,8 @@ export function createCreateHandler() {
|
||||
if (existingWorktree) {
|
||||
// Worktree already exists, return it as success (not an error)
|
||||
// This handles manually created worktrees or worktrees from previous runs
|
||||
console.log(
|
||||
`[Worktree] Found existing worktree for branch "${branchName}" at: ${existingWorktree.path}`
|
||||
logger.info(
|
||||
`Found existing worktree for branch "${branchName}" at: ${existingWorktree.path}`
|
||||
);
|
||||
|
||||
// Track the branch so it persists in the UI
|
||||
|
||||
@@ -38,7 +38,7 @@ export function createDeleteHandler() {
|
||||
// Get branch name before removing worktree
|
||||
let branchName: string | null = null;
|
||||
try {
|
||||
const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||
const { stdout } = await execAsync('git symbolic-ref --short HEAD', {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
branchName = stdout.trim();
|
||||
|
||||
@@ -31,7 +31,7 @@ export function createInfoHandler() {
|
||||
const worktreePath = path.join(projectPath, '.worktrees', featureId);
|
||||
try {
|
||||
await secureFs.access(worktreePath);
|
||||
const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||
const { stdout } = await execAsync('git symbolic-ref --short HEAD', {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
res.json({
|
||||
|
||||
@@ -34,7 +34,7 @@ export function createListBranchesHandler() {
|
||||
}
|
||||
|
||||
// Get current branch
|
||||
const { stdout: currentBranchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||
const { stdout: currentBranchOutput } = await execAsync('git symbolic-ref --short HEAD', {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
const currentBranch = currentBranchOutput.trim();
|
||||
|
||||
@@ -35,7 +35,7 @@ export function createMergeHandler() {
|
||||
const worktreePath = path.join(projectPath, '.worktrees', featureId);
|
||||
|
||||
// Get current branch
|
||||
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||
const { stdout: currentBranch } = await execAsync('git symbolic-ref --short HEAD', {
|
||||
cwd: projectPath,
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ import {
|
||||
isValidBranchName,
|
||||
isGhCliAvailable,
|
||||
} from '../common.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('PRInfo');
|
||||
|
||||
export interface PRComment {
|
||||
id: number;
|
||||
@@ -174,7 +177,7 @@ export function createPRInfoHandler() {
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('[PRInfo] Failed to fetch PR comments:', error);
|
||||
logger.warn('Failed to fetch PR comments:', error);
|
||||
}
|
||||
|
||||
// Get review comments (inline code comments)
|
||||
@@ -209,10 +212,10 @@ export function createPRInfoHandler() {
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('[PRInfo] Failed to fetch review comments:', error);
|
||||
logger.warn('Failed to fetch review comments:', error);
|
||||
}
|
||||
} else {
|
||||
console.warn('[PRInfo] Cannot fetch review comments: repository info not available');
|
||||
logger.warn('Cannot fetch review comments: repository info not available');
|
||||
}
|
||||
|
||||
const prInfo: PRInfo = {
|
||||
|
||||
@@ -28,7 +28,7 @@ export function createPullHandler() {
|
||||
}
|
||||
|
||||
// Get current branch name
|
||||
const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||
const { stdout: branchOutput } = await execAsync('git symbolic-ref --short HEAD', {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
const branchName = branchOutput.trim();
|
||||
|
||||
@@ -29,7 +29,7 @@ export function createPushHandler() {
|
||||
}
|
||||
|
||||
// Get branch name
|
||||
const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||
const { stdout: branchOutput } = await execAsync('git symbolic-ref --short HEAD', {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
const branchName = branchOutput.trim();
|
||||
|
||||
@@ -87,7 +87,7 @@ export function createSwitchBranchHandler() {
|
||||
}
|
||||
|
||||
// Get current branch
|
||||
const { stdout: currentBranchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||
const { stdout: currentBranchOutput } = await execAsync('git symbolic-ref --short HEAD', {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
const previousBranch = currentBranchOutput.trim();
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import path from 'path';
|
||||
import * as secureFs from '../lib/secure-fs.js';
|
||||
import type { EventEmitter } from '../lib/events.js';
|
||||
import type { ExecuteOptions } from '@automaker/types';
|
||||
import type { ExecuteOptions, ThinkingLevel } from '@automaker/types';
|
||||
import {
|
||||
readImageAsBase64,
|
||||
buildPromptWithImages,
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
getEnableSandboxModeSetting,
|
||||
filterClaudeMdFromContext,
|
||||
getMCPServersFromSettings,
|
||||
getMCPPermissionSettings,
|
||||
getPromptCustomization,
|
||||
} from '../lib/settings-helpers.js';
|
||||
|
||||
@@ -45,6 +44,7 @@ interface QueuedPrompt {
|
||||
message: string;
|
||||
imagePaths?: string[];
|
||||
model?: string;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
addedAt: string;
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ interface Session {
|
||||
abortController: AbortController | null;
|
||||
workingDirectory: string;
|
||||
model?: string;
|
||||
thinkingLevel?: ThinkingLevel; // Thinking level for Claude models
|
||||
sdkSessionId?: string; // Claude SDK session ID for conversation continuity
|
||||
promptQueue: QueuedPrompt[]; // Queue of prompts to auto-run after current task
|
||||
}
|
||||
@@ -142,12 +143,14 @@ export class AgentService {
|
||||
workingDirectory,
|
||||
imagePaths,
|
||||
model,
|
||||
thinkingLevel,
|
||||
}: {
|
||||
sessionId: string;
|
||||
message: string;
|
||||
workingDirectory?: string;
|
||||
imagePaths?: string[];
|
||||
model?: string;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
}) {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
@@ -160,11 +163,14 @@ export class AgentService {
|
||||
throw new Error('Agent is already processing a message');
|
||||
}
|
||||
|
||||
// Update session model if provided
|
||||
// Update session model and thinking level if provided
|
||||
if (model) {
|
||||
session.model = model;
|
||||
await this.updateSession(sessionId, { model });
|
||||
}
|
||||
if (thinkingLevel !== undefined) {
|
||||
session.thinkingLevel = thinkingLevel;
|
||||
}
|
||||
|
||||
// Read images and convert to base64
|
||||
const images: Message['images'] = [];
|
||||
@@ -235,9 +241,6 @@ export class AgentService {
|
||||
// Load MCP servers from settings (global setting only)
|
||||
const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AgentService]');
|
||||
|
||||
// Load MCP permission settings (global setting only)
|
||||
const mcpPermissions = await getMCPPermissionSettings(this.settingsService, '[AgentService]');
|
||||
|
||||
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.)
|
||||
const contextResult = await loadContextFiles({
|
||||
projectPath: effectiveWorkDir,
|
||||
@@ -255,6 +258,8 @@ export class AgentService {
|
||||
: baseSystemPrompt;
|
||||
|
||||
// Build SDK options using centralized configuration
|
||||
// Use thinking level from request, or fall back to session's stored thinking level
|
||||
const effectiveThinkingLevel = thinkingLevel ?? session.thinkingLevel;
|
||||
const sdkOptions = createChatOptions({
|
||||
cwd: effectiveWorkDir,
|
||||
model: model,
|
||||
@@ -263,9 +268,8 @@ export class AgentService {
|
||||
abortController: session.abortController!,
|
||||
autoLoadClaudeMd,
|
||||
enableSandboxMode,
|
||||
thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models
|
||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
|
||||
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools,
|
||||
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools,
|
||||
});
|
||||
|
||||
// Extract model, maxTurns, and allowedTools from SDK options
|
||||
@@ -290,8 +294,6 @@ export class AgentService {
|
||||
sandbox: sdkOptions.sandbox, // Pass sandbox configuration
|
||||
sdkSessionId: session.sdkSessionId, // Pass SDK session ID for resuming
|
||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration
|
||||
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools, // Pass MCP auto-approve setting
|
||||
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools, // Pass MCP unrestricted tools setting
|
||||
};
|
||||
|
||||
// Build prompt content with images
|
||||
@@ -628,7 +630,12 @@ export class AgentService {
|
||||
*/
|
||||
async addToQueue(
|
||||
sessionId: string,
|
||||
prompt: { message: string; imagePaths?: string[]; model?: string }
|
||||
prompt: {
|
||||
message: string;
|
||||
imagePaths?: string[];
|
||||
model?: string;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
}
|
||||
): Promise<{ success: boolean; queuedPrompt?: QueuedPrompt; error?: string }> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
@@ -640,6 +647,7 @@ export class AgentService {
|
||||
message: prompt.message,
|
||||
imagePaths: prompt.imagePaths,
|
||||
model: prompt.model,
|
||||
thinkingLevel: prompt.thinkingLevel,
|
||||
addedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
@@ -769,6 +777,7 @@ export class AgentService {
|
||||
message: nextPrompt.message,
|
||||
imagePaths: nextPrompt.imagePaths,
|
||||
model: nextPrompt.model,
|
||||
thinkingLevel: nextPrompt.thinkingLevel,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to process queued prompt:', error);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
280
apps/server/src/services/cursor-config-service.ts
Normal file
280
apps/server/src/services/cursor-config-service.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* Cursor Config Service
|
||||
*
|
||||
* Manages Cursor CLI permissions configuration files:
|
||||
* - Global: ~/.cursor/cli-config.json
|
||||
* - Project: <project>/.cursor/cli.json
|
||||
*
|
||||
* Based on: https://cursor.com/docs/cli/reference/configuration
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type {
|
||||
CursorCliConfigFile,
|
||||
CursorCliPermissions,
|
||||
CursorPermissionProfile,
|
||||
} from '@automaker/types';
|
||||
import {
|
||||
CURSOR_STRICT_PROFILE,
|
||||
CURSOR_DEVELOPMENT_PROFILE,
|
||||
CURSOR_PERMISSION_PROFILES,
|
||||
} from '@automaker/types';
|
||||
|
||||
const logger = createLogger('CursorConfigService');
|
||||
|
||||
/**
|
||||
* Get the path to the global Cursor CLI config
|
||||
*/
|
||||
export function getGlobalConfigPath(): string {
|
||||
// Windows: $env:USERPROFILE\.cursor\cli-config.json
|
||||
// macOS/Linux: ~/.cursor/cli-config.json
|
||||
// XDG_CONFIG_HOME override on Linux: $XDG_CONFIG_HOME/cursor/cli-config.json
|
||||
const xdgConfig = process.env.XDG_CONFIG_HOME;
|
||||
const cursorConfigDir = process.env.CURSOR_CONFIG_DIR;
|
||||
|
||||
if (cursorConfigDir) {
|
||||
return path.join(cursorConfigDir, 'cli-config.json');
|
||||
}
|
||||
|
||||
if (process.platform === 'linux' && xdgConfig) {
|
||||
return path.join(xdgConfig, 'cursor', 'cli-config.json');
|
||||
}
|
||||
|
||||
return path.join(os.homedir(), '.cursor', 'cli-config.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to a project's Cursor CLI config
|
||||
*/
|
||||
export function getProjectConfigPath(projectPath: string): string {
|
||||
return path.join(projectPath, '.cursor', 'cli.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the global Cursor CLI config
|
||||
*/
|
||||
export async function readGlobalConfig(): Promise<CursorCliConfigFile | null> {
|
||||
const configPath = getGlobalConfigPath();
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(configPath, 'utf-8');
|
||||
const config = JSON.parse(content) as CursorCliConfigFile;
|
||||
logger.debug('Read global Cursor config from:', configPath);
|
||||
return config;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
logger.debug('Global Cursor config not found at:', configPath);
|
||||
return null;
|
||||
}
|
||||
logger.error('Failed to read global Cursor config:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the global Cursor CLI config
|
||||
*/
|
||||
export async function writeGlobalConfig(config: CursorCliConfigFile): Promise<void> {
|
||||
const configPath = getGlobalConfigPath();
|
||||
const configDir = path.dirname(configPath);
|
||||
|
||||
// Ensure directory exists
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
|
||||
// Write config
|
||||
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
|
||||
logger.info('Wrote global Cursor config to:', configPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a project's Cursor CLI config
|
||||
*/
|
||||
export async function readProjectConfig(projectPath: string): Promise<CursorCliConfigFile | null> {
|
||||
const configPath = getProjectConfigPath(projectPath);
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(configPath, 'utf-8');
|
||||
const config = JSON.parse(content) as CursorCliConfigFile;
|
||||
logger.debug('Read project Cursor config from:', configPath);
|
||||
return config;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
logger.debug('Project Cursor config not found at:', configPath);
|
||||
return null;
|
||||
}
|
||||
logger.error('Failed to read project Cursor config:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a project's Cursor CLI config
|
||||
*
|
||||
* Note: Project-level config ONLY supports permissions.
|
||||
* The version field and other settings are global-only.
|
||||
* See: https://cursor.com/docs/cli/reference/configuration
|
||||
*/
|
||||
export async function writeProjectConfig(
|
||||
projectPath: string,
|
||||
config: CursorCliConfigFile
|
||||
): Promise<void> {
|
||||
const configPath = getProjectConfigPath(projectPath);
|
||||
const configDir = path.dirname(configPath);
|
||||
|
||||
// Ensure .cursor directory exists
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
|
||||
// Write config (project config ONLY supports permissions - no version field!)
|
||||
const projectConfig = {
|
||||
permissions: config.permissions,
|
||||
};
|
||||
|
||||
await fs.writeFile(configPath, JSON.stringify(projectConfig, null, 2));
|
||||
logger.info('Wrote project Cursor config to:', configPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a project's Cursor CLI config
|
||||
*/
|
||||
export async function deleteProjectConfig(projectPath: string): Promise<void> {
|
||||
const configPath = getProjectConfigPath(projectPath);
|
||||
|
||||
try {
|
||||
await fs.unlink(configPath);
|
||||
logger.info('Deleted project Cursor config:', configPath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective permissions for a project
|
||||
* Project config takes precedence over global config
|
||||
*/
|
||||
export async function getEffectivePermissions(
|
||||
projectPath?: string
|
||||
): Promise<CursorCliPermissions | null> {
|
||||
// Try project config first
|
||||
if (projectPath) {
|
||||
const projectConfig = await readProjectConfig(projectPath);
|
||||
if (projectConfig?.permissions) {
|
||||
return projectConfig.permissions;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to global config
|
||||
const globalConfig = await readGlobalConfig();
|
||||
return globalConfig?.permissions || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a predefined permission profile to a project
|
||||
*/
|
||||
export async function applyProfileToProject(
|
||||
projectPath: string,
|
||||
profileId: CursorPermissionProfile
|
||||
): Promise<void> {
|
||||
const profile = CURSOR_PERMISSION_PROFILES.find((p) => p.id === profileId);
|
||||
|
||||
if (!profile) {
|
||||
throw new Error(`Unknown permission profile: ${profileId}`);
|
||||
}
|
||||
|
||||
await writeProjectConfig(projectPath, {
|
||||
version: 1,
|
||||
permissions: profile.permissions,
|
||||
});
|
||||
|
||||
logger.info(`Applied "${profile.name}" profile to project:`, projectPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a predefined permission profile globally
|
||||
*/
|
||||
export async function applyProfileGlobally(profileId: CursorPermissionProfile): Promise<void> {
|
||||
const profile = CURSOR_PERMISSION_PROFILES.find((p) => p.id === profileId);
|
||||
|
||||
if (!profile) {
|
||||
throw new Error(`Unknown permission profile: ${profileId}`);
|
||||
}
|
||||
|
||||
// Read existing global config to preserve other settings
|
||||
const existingConfig = await readGlobalConfig();
|
||||
|
||||
await writeGlobalConfig({
|
||||
version: 1,
|
||||
...existingConfig,
|
||||
permissions: profile.permissions,
|
||||
});
|
||||
|
||||
logger.info(`Applied "${profile.name}" profile globally`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect which profile matches the current permissions
|
||||
*/
|
||||
export function detectProfile(
|
||||
permissions: CursorCliPermissions | null
|
||||
): CursorPermissionProfile | null {
|
||||
if (!permissions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if permissions match a predefined profile
|
||||
for (const profile of CURSOR_PERMISSION_PROFILES) {
|
||||
const allowMatch =
|
||||
JSON.stringify(profile.permissions.allow.sort()) === JSON.stringify(permissions.allow.sort());
|
||||
const denyMatch =
|
||||
JSON.stringify(profile.permissions.deny.sort()) === JSON.stringify(permissions.deny.sort());
|
||||
|
||||
if (allowMatch && denyMatch) {
|
||||
return profile.id;
|
||||
}
|
||||
}
|
||||
|
||||
return 'custom';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate example config file content
|
||||
*/
|
||||
export function generateExampleConfig(profileId: CursorPermissionProfile = 'development'): string {
|
||||
const profile =
|
||||
CURSOR_PERMISSION_PROFILES.find((p) => p.id === profileId) || CURSOR_DEVELOPMENT_PROFILE;
|
||||
|
||||
const config: CursorCliConfigFile = {
|
||||
version: 1,
|
||||
permissions: profile.permissions,
|
||||
};
|
||||
|
||||
return JSON.stringify(config, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a project has Cursor CLI config
|
||||
*/
|
||||
export async function hasProjectConfig(projectPath: string): Promise<boolean> {
|
||||
const configPath = getProjectConfigPath(projectPath);
|
||||
|
||||
try {
|
||||
await fs.access(configPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available permission profiles
|
||||
*/
|
||||
export function getAvailableProfiles() {
|
||||
return CURSOR_PERMISSION_PROFILES;
|
||||
}
|
||||
|
||||
// Export profile constants for convenience
|
||||
export { CURSOR_STRICT_PROFILE, CURSOR_DEVELOPMENT_PROFILE };
|
||||
@@ -11,6 +11,9 @@ import { spawn, execSync, type ChildProcess } from 'child_process';
|
||||
import * as secureFs from '../lib/secure-fs.js';
|
||||
import path from 'path';
|
||||
import net from 'net';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('DevServerService');
|
||||
|
||||
export interface DevServerInfo {
|
||||
worktreePath: string;
|
||||
@@ -69,7 +72,7 @@ class DevServerService {
|
||||
for (const pid of pids) {
|
||||
try {
|
||||
execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore' });
|
||||
console.log(`[DevServerService] Killed process ${pid} on port ${port}`);
|
||||
logger.debug(`Killed process ${pid} on port ${port}`);
|
||||
} catch {
|
||||
// Process may have already exited
|
||||
}
|
||||
@@ -82,7 +85,7 @@ class DevServerService {
|
||||
for (const pid of pids) {
|
||||
try {
|
||||
execSync(`kill -9 ${pid}`, { stdio: 'ignore' });
|
||||
console.log(`[DevServerService] Killed process ${pid} on port ${port}`);
|
||||
logger.debug(`Killed process ${pid} on port ${port}`);
|
||||
} catch {
|
||||
// Process may have already exited
|
||||
}
|
||||
@@ -93,7 +96,7 @@ class DevServerService {
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore errors - port might not have any process
|
||||
console.log(`[DevServerService] No process to kill on port ${port}`);
|
||||
logger.debug(`No process to kill on port ${port}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,11 +254,9 @@ class DevServerService {
|
||||
// Small delay to ensure related ports are freed
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
console.log(`[DevServerService] Starting dev server on port ${port}`);
|
||||
console.log(`[DevServerService] Working directory (cwd): ${worktreePath}`);
|
||||
console.log(
|
||||
`[DevServerService] Command: ${devCommand.cmd} ${devCommand.args.join(' ')} with PORT=${port}`
|
||||
);
|
||||
logger.info(`Starting dev server on port ${port}`);
|
||||
logger.debug(`Working directory (cwd): ${worktreePath}`);
|
||||
logger.debug(`Command: ${devCommand.cmd} ${devCommand.args.join(' ')} with PORT=${port}`);
|
||||
|
||||
// Spawn the dev process with PORT environment variable
|
||||
const env = {
|
||||
@@ -276,26 +277,26 @@ class DevServerService {
|
||||
// Log output for debugging
|
||||
if (devProcess.stdout) {
|
||||
devProcess.stdout.on('data', (data: Buffer) => {
|
||||
console.log(`[DevServer:${port}] ${data.toString().trim()}`);
|
||||
logger.debug(`[Port${port}] ${data.toString().trim()}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (devProcess.stderr) {
|
||||
devProcess.stderr.on('data', (data: Buffer) => {
|
||||
const msg = data.toString().trim();
|
||||
console.error(`[DevServer:${port}] ${msg}`);
|
||||
logger.debug(`[Port${port}] ${msg}`);
|
||||
});
|
||||
}
|
||||
|
||||
devProcess.on('error', (error) => {
|
||||
console.error(`[DevServerService] Process error:`, error);
|
||||
logger.error(`Process error:`, error);
|
||||
status.error = error.message;
|
||||
this.allocatedPorts.delete(port);
|
||||
this.runningServers.delete(worktreePath);
|
||||
});
|
||||
|
||||
devProcess.on('exit', (code) => {
|
||||
console.log(`[DevServerService] Process for ${worktreePath} exited with code ${code}`);
|
||||
logger.info(`Process for ${worktreePath} exited with code ${code}`);
|
||||
status.exited = true;
|
||||
this.allocatedPorts.delete(port);
|
||||
this.runningServers.delete(worktreePath);
|
||||
@@ -352,9 +353,7 @@ class DevServerService {
|
||||
// If we don't have a record of this server, it may have crashed/exited on its own
|
||||
// Return success so the frontend can clear its state
|
||||
if (!server) {
|
||||
console.log(
|
||||
`[DevServerService] No server record for ${worktreePath}, may have already stopped`
|
||||
);
|
||||
logger.debug(`No server record for ${worktreePath}, may have already stopped`);
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
@@ -364,7 +363,7 @@ class DevServerService {
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`[DevServerService] Stopping dev server for ${worktreePath}`);
|
||||
logger.info(`Stopping dev server for ${worktreePath}`);
|
||||
|
||||
// Kill the process
|
||||
if (server.process && !server.process.killed) {
|
||||
@@ -434,7 +433,7 @@ class DevServerService {
|
||||
* Stop all running dev servers (for cleanup)
|
||||
*/
|
||||
async stopAll(): Promise<void> {
|
||||
console.log(`[DevServerService] Stopping all ${this.runningServers.size} dev servers`);
|
||||
logger.info(`Stopping all ${this.runningServers.size} dev servers`);
|
||||
|
||||
for (const [worktreePath] of this.runningServers) {
|
||||
await this.stopDevServer(worktreePath);
|
||||
|
||||
@@ -56,10 +56,10 @@ export class FeatureLoader {
|
||||
try {
|
||||
// Paths are now absolute
|
||||
await secureFs.unlink(oldPath);
|
||||
console.log(`[FeatureLoader] Deleted orphaned image: ${oldPath}`);
|
||||
logger.info(`Deleted orphaned image: ${oldPath}`);
|
||||
} catch (error) {
|
||||
// Ignore errors when deleting (file may already be gone)
|
||||
logger.warn(`[FeatureLoader] Failed to delete image: ${oldPath}`, error);
|
||||
logger.warn(`Failed to delete image: ${oldPath}`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,7 @@ export class FeatureLoader {
|
||||
try {
|
||||
await secureFs.access(fullOriginalPath);
|
||||
} catch {
|
||||
logger.warn(`[FeatureLoader] Image not found, skipping: ${fullOriginalPath}`);
|
||||
logger.warn(`Image not found, skipping: ${fullOriginalPath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ export class FeatureLoader {
|
||||
|
||||
// Copy the file
|
||||
await secureFs.copyFile(fullOriginalPath, newPath);
|
||||
console.log(`[FeatureLoader] Copied image: ${originalPath} -> ${newPath}`);
|
||||
logger.info(`Copied image: ${originalPath} -> ${newPath}`);
|
||||
|
||||
// Try to delete the original temp file
|
||||
try {
|
||||
@@ -158,6 +158,13 @@ export class FeatureLoader {
|
||||
return path.join(this.getFeatureDir(projectPath, featureId), 'agent-output.md');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to a feature's raw-output.jsonl file
|
||||
*/
|
||||
getRawOutputPath(projectPath: string, featureId: string): string {
|
||||
return path.join(this.getFeatureDir(projectPath, featureId), 'raw-output.jsonl');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new feature ID
|
||||
*/
|
||||
@@ -195,9 +202,7 @@ export class FeatureLoader {
|
||||
const feature = JSON.parse(content);
|
||||
|
||||
if (!feature.id) {
|
||||
logger.warn(
|
||||
`[FeatureLoader] Feature ${featureId} missing required 'id' field, skipping`
|
||||
);
|
||||
logger.warn(`Feature ${featureId} missing required 'id' field, skipping`);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -206,14 +211,9 @@ export class FeatureLoader {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return null;
|
||||
} else if (error instanceof SyntaxError) {
|
||||
logger.warn(
|
||||
`[FeatureLoader] Failed to parse feature.json for ${featureId}: ${error.message}`
|
||||
);
|
||||
logger.warn(`Failed to parse feature.json for ${featureId}: ${error.message}`);
|
||||
} else {
|
||||
logger.error(
|
||||
`[FeatureLoader] Failed to load feature ${featureId}:`,
|
||||
(error as Error).message
|
||||
);
|
||||
logger.error(`Failed to load feature ${featureId}:`, (error as Error).message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -248,7 +248,7 @@ export class FeatureLoader {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
logger.error(`[FeatureLoader] Failed to get feature ${featureId}:`, error);
|
||||
logger.error(`Failed to get feature ${featureId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -335,10 +335,10 @@ export class FeatureLoader {
|
||||
try {
|
||||
const featureDir = this.getFeatureDir(projectPath, featureId);
|
||||
await secureFs.rm(featureDir, { recursive: true, force: true });
|
||||
console.log(`[FeatureLoader] Deleted feature ${featureId}`);
|
||||
logger.info(`Deleted feature ${featureId}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`[FeatureLoader] Failed to delete feature ${featureId}:`, error);
|
||||
logger.error(`Failed to delete feature ${featureId}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -355,7 +355,24 @@ export class FeatureLoader {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
logger.error(`[FeatureLoader] Failed to get agent output for ${featureId}:`, error);
|
||||
logger.error(`Failed to get agent output for ${featureId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get raw output for a feature (JSONL format for debugging)
|
||||
*/
|
||||
async getRawOutput(projectPath: string, featureId: string): Promise<string | null> {
|
||||
try {
|
||||
const rawOutputPath = this.getRawOutputPath(projectPath, featureId);
|
||||
const content = (await secureFs.readFile(rawOutputPath, 'utf-8')) as string;
|
||||
return content;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
logger.error(`Failed to get raw output for ${featureId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
1722
apps/server/src/services/ideation-service.ts
Normal file
1722
apps/server/src/services/ideation-service.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -9,10 +9,14 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import type { MCPServerConfig, MCPToolInfo } from '@automaker/types';
|
||||
import type { SettingsService } from './settings-service.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const DEFAULT_TIMEOUT = 10000; // 10 seconds
|
||||
const IS_WINDOWS = process.platform === 'win32';
|
||||
|
||||
export interface MCPTestResult {
|
||||
success: boolean;
|
||||
@@ -41,6 +45,11 @@ export class MCPTestService {
|
||||
async testServer(serverConfig: MCPServerConfig): Promise<MCPTestResult> {
|
||||
const startTime = Date.now();
|
||||
let client: Client | null = null;
|
||||
let transport:
|
||||
| StdioClientTransport
|
||||
| SSEClientTransport
|
||||
| StreamableHTTPClientTransport
|
||||
| null = null;
|
||||
|
||||
try {
|
||||
client = new Client({
|
||||
@@ -49,7 +58,7 @@ export class MCPTestService {
|
||||
});
|
||||
|
||||
// Create transport based on server type
|
||||
const transport = await this.createTransport(serverConfig);
|
||||
transport = await this.createTransport(serverConfig);
|
||||
|
||||
// Connect with timeout
|
||||
await Promise.race([
|
||||
@@ -98,13 +107,47 @@ export class MCPTestService {
|
||||
connectionTime,
|
||||
};
|
||||
} finally {
|
||||
// Clean up client connection
|
||||
if (client) {
|
||||
try {
|
||||
await client.close();
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
// Clean up client connection and ensure process termination
|
||||
await this.cleanupConnection(client, transport);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up MCP client connection and terminate spawned processes
|
||||
*
|
||||
* On Windows, child processes spawned via 'cmd /c' don't get terminated when the
|
||||
* parent process is killed. We use taskkill with /t flag to kill the entire process tree.
|
||||
* This prevents orphaned MCP server processes that would spam logs with ping warnings.
|
||||
*
|
||||
* IMPORTANT: We must run taskkill BEFORE client.close() because:
|
||||
* - client.close() kills only the parent cmd.exe process
|
||||
* - This orphans the child node.exe processes before we can kill them
|
||||
* - taskkill /t needs the parent PID to exist to traverse the process tree
|
||||
*/
|
||||
private async cleanupConnection(
|
||||
client: Client | null,
|
||||
transport: StdioClientTransport | SSEClientTransport | StreamableHTTPClientTransport | null
|
||||
): Promise<void> {
|
||||
// Get the PID before any cleanup (only available for stdio transports)
|
||||
const pid = transport instanceof StdioClientTransport ? transport.pid : null;
|
||||
|
||||
// On Windows with stdio transport, kill the entire process tree FIRST
|
||||
// This must happen before client.close() which would orphan child processes
|
||||
if (IS_WINDOWS && pid) {
|
||||
try {
|
||||
// taskkill /f = force, /t = kill process tree, /pid = process ID
|
||||
await execAsync(`taskkill /f /t /pid ${pid}`);
|
||||
} catch {
|
||||
// Process may have already exited, which is fine
|
||||
}
|
||||
}
|
||||
|
||||
// Now do the standard close (may be a no-op if taskkill already killed everything)
|
||||
if (client) {
|
||||
try {
|
||||
await client.close();
|
||||
} catch {
|
||||
// Expected if taskkill already terminated the process
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,11 +27,14 @@ import type {
|
||||
TrashedProjectRef,
|
||||
BoardBackgroundSettings,
|
||||
WorktreeInfo,
|
||||
PhaseModelConfig,
|
||||
PhaseModelEntry,
|
||||
} from '../types/settings.js';
|
||||
import {
|
||||
DEFAULT_GLOBAL_SETTINGS,
|
||||
DEFAULT_CREDENTIALS,
|
||||
DEFAULT_PROJECT_SETTINGS,
|
||||
DEFAULT_PHASE_MODELS,
|
||||
SETTINGS_VERSION,
|
||||
CREDENTIALS_VERSION,
|
||||
PROJECT_SETTINGS_VERSION,
|
||||
@@ -132,6 +135,9 @@ export class SettingsService {
|
||||
const settingsPath = getGlobalSettingsPath(this.dataDir);
|
||||
const settings = await readJsonFile<GlobalSettings>(settingsPath, DEFAULT_GLOBAL_SETTINGS);
|
||||
|
||||
// Migrate legacy enhancementModel/validationModel to phaseModels
|
||||
const migratedPhaseModels = this.migratePhaseModels(settings);
|
||||
|
||||
// Apply any missing defaults (for backwards compatibility)
|
||||
let result: GlobalSettings = {
|
||||
...DEFAULT_GLOBAL_SETTINGS,
|
||||
@@ -140,6 +146,7 @@ export class SettingsService {
|
||||
...DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts,
|
||||
...settings.keyboardShortcuts,
|
||||
},
|
||||
phaseModels: migratedPhaseModels,
|
||||
};
|
||||
|
||||
// Version-based migrations
|
||||
@@ -151,10 +158,23 @@ export class SettingsService {
|
||||
if (storedVersion < 2) {
|
||||
logger.info('Migrating settings from v1 to v2: disabling sandbox mode');
|
||||
result.enableSandboxMode = false;
|
||||
result.version = SETTINGS_VERSION;
|
||||
needsSave = true;
|
||||
}
|
||||
|
||||
// Migration v2 -> v3: Convert string phase models to PhaseModelEntry objects
|
||||
// Note: migratePhaseModels() handles the actual conversion for both v1 and v2 formats
|
||||
if (storedVersion < 3) {
|
||||
logger.info(
|
||||
`Migrating settings from v${storedVersion} to v3: converting phase models to PhaseModelEntry format`
|
||||
);
|
||||
needsSave = true;
|
||||
}
|
||||
|
||||
// Update version if any migration occurred
|
||||
if (needsSave) {
|
||||
result.version = SETTINGS_VERSION;
|
||||
}
|
||||
|
||||
// Save migrated settings if needed
|
||||
if (needsSave) {
|
||||
try {
|
||||
@@ -169,6 +189,67 @@ export class SettingsService {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate legacy enhancementModel/validationModel fields to phaseModels structure
|
||||
*
|
||||
* Handles backwards compatibility for settings created before phaseModels existed.
|
||||
* Also handles migration from string phase models (v2) to PhaseModelEntry objects (v3).
|
||||
* Legacy fields take precedence over defaults but phaseModels takes precedence over legacy.
|
||||
*
|
||||
* @param settings - Raw settings from file
|
||||
* @returns Complete PhaseModelConfig with all fields populated
|
||||
*/
|
||||
private migratePhaseModels(settings: Partial<GlobalSettings>): PhaseModelConfig {
|
||||
// Start with defaults
|
||||
const result: PhaseModelConfig = { ...DEFAULT_PHASE_MODELS };
|
||||
|
||||
// If phaseModels exists, use it (with defaults for any missing fields)
|
||||
if (settings.phaseModels) {
|
||||
// Merge with defaults and convert any string values to PhaseModelEntry
|
||||
const merged: PhaseModelConfig = { ...DEFAULT_PHASE_MODELS };
|
||||
for (const key of Object.keys(settings.phaseModels) as Array<keyof PhaseModelConfig>) {
|
||||
const value = settings.phaseModels[key];
|
||||
if (value !== undefined) {
|
||||
// Convert string to PhaseModelEntry if needed (v2 -> v3 migration)
|
||||
merged[key] = this.toPhaseModelEntry(value);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
// Migrate legacy fields if phaseModels doesn't exist
|
||||
// These were the only two legacy fields that existed
|
||||
if (settings.enhancementModel) {
|
||||
result.enhancementModel = this.toPhaseModelEntry(settings.enhancementModel);
|
||||
logger.debug(`Migrated legacy enhancementModel: ${settings.enhancementModel}`);
|
||||
}
|
||||
if (settings.validationModel) {
|
||||
result.validationModel = this.toPhaseModelEntry(settings.validationModel);
|
||||
logger.debug(`Migrated legacy validationModel: ${settings.validationModel}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a phase model value to PhaseModelEntry format
|
||||
*
|
||||
* Handles migration from string format (v2) to object format (v3).
|
||||
* - String values like 'sonnet' become { model: 'sonnet' }
|
||||
* - Object values are returned as-is (with type assertion)
|
||||
*
|
||||
* @param value - Phase model value (string or PhaseModelEntry)
|
||||
* @returns PhaseModelEntry object
|
||||
*/
|
||||
private toPhaseModelEntry(value: string | PhaseModelEntry): PhaseModelEntry {
|
||||
if (typeof value === 'string') {
|
||||
// v2 format: just a model string
|
||||
return { model: value as PhaseModelEntry['model'] };
|
||||
}
|
||||
// v3 format: already a PhaseModelEntry object
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update global settings with partial changes
|
||||
*
|
||||
@@ -197,6 +278,14 @@ export class SettingsService {
|
||||
};
|
||||
}
|
||||
|
||||
// Deep merge phaseModels if provided
|
||||
if (updates.phaseModels) {
|
||||
updated.phaseModels = {
|
||||
...current.phaseModels,
|
||||
...updates.phaseModels,
|
||||
};
|
||||
}
|
||||
|
||||
await atomicWriteJson(settingsPath, updated);
|
||||
logger.info('Global settings updated');
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ import * as path from 'path';
|
||||
// secureFs is used for user-controllable paths (working directory validation)
|
||||
// to enforce ALLOWED_ROOT_DIRECTORY security boundary
|
||||
import * as secureFs from '../lib/secure-fs.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('Terminal');
|
||||
// System paths module handles shell binary checks and WSL detection
|
||||
// These are system paths outside ALLOWED_ROOT_DIRECTORY, centralized for security auditing
|
||||
import {
|
||||
@@ -219,7 +222,7 @@ export class TerminalService extends EventEmitter {
|
||||
|
||||
// Reject paths with null bytes (could bypass path checks)
|
||||
if (cwd.includes('\0')) {
|
||||
console.warn(`[Terminal] Rejecting path with null byte: ${cwd.replace(/\0/g, '\\0')}`);
|
||||
logger.warn(`Rejecting path with null byte: ${cwd.replace(/\0/g, '\\0')}`);
|
||||
return homeDir;
|
||||
}
|
||||
|
||||
@@ -242,12 +245,10 @@ export class TerminalService extends EventEmitter {
|
||||
if (statResult.isDirectory()) {
|
||||
return cwd;
|
||||
}
|
||||
console.warn(`[Terminal] Path exists but is not a directory: ${cwd}, falling back to home`);
|
||||
logger.warn(`Path exists but is not a directory: ${cwd}, falling back to home`);
|
||||
return homeDir;
|
||||
} catch {
|
||||
console.warn(
|
||||
`[Terminal] Working directory does not exist or not allowed: ${cwd}, falling back to home`
|
||||
);
|
||||
logger.warn(`Working directory does not exist or not allowed: ${cwd}, falling back to home`);
|
||||
return homeDir;
|
||||
}
|
||||
}
|
||||
@@ -272,7 +273,7 @@ export class TerminalService extends EventEmitter {
|
||||
setMaxSessions(limit: number): void {
|
||||
if (limit >= MIN_MAX_SESSIONS && limit <= MAX_MAX_SESSIONS) {
|
||||
maxSessions = limit;
|
||||
console.log(`[Terminal] Max sessions limit updated to ${limit}`);
|
||||
logger.info(`Max sessions limit updated to ${limit}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,7 +284,7 @@ export class TerminalService extends EventEmitter {
|
||||
async createSession(options: TerminalOptions = {}): Promise<TerminalSession | null> {
|
||||
// Check session limit
|
||||
if (this.sessions.size >= maxSessions) {
|
||||
console.error(`[Terminal] Max sessions (${maxSessions}) reached, refusing new session`);
|
||||
logger.error(`Max sessions (${maxSessions}) reached, refusing new session`);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -319,7 +320,7 @@ export class TerminalService extends EventEmitter {
|
||||
...options.env,
|
||||
};
|
||||
|
||||
console.log(`[Terminal] Creating session ${id} with shell: ${shell} in ${cwd}`);
|
||||
logger.info(`Creating session ${id} with shell: ${shell} in ${cwd}`);
|
||||
|
||||
const ptyProcess = pty.spawn(shell, shellArgs, {
|
||||
name: 'xterm-256color',
|
||||
@@ -391,13 +392,13 @@ export class TerminalService extends EventEmitter {
|
||||
|
||||
// Handle exit
|
||||
ptyProcess.onExit(({ exitCode }) => {
|
||||
console.log(`[Terminal] Session ${id} exited with code ${exitCode}`);
|
||||
logger.info(`Session ${id} exited with code ${exitCode}`);
|
||||
this.sessions.delete(id);
|
||||
this.exitCallbacks.forEach((cb) => cb(id, exitCode));
|
||||
this.emit('exit', id, exitCode);
|
||||
});
|
||||
|
||||
console.log(`[Terminal] Session ${id} created successfully`);
|
||||
logger.info(`Session ${id} created successfully`);
|
||||
return session;
|
||||
}
|
||||
|
||||
@@ -407,7 +408,7 @@ export class TerminalService extends EventEmitter {
|
||||
write(sessionId: string, data: string): boolean {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
console.warn(`[Terminal] Session ${sessionId} not found`);
|
||||
logger.warn(`Session ${sessionId} not found`);
|
||||
return false;
|
||||
}
|
||||
session.pty.write(data);
|
||||
@@ -422,7 +423,7 @@ export class TerminalService extends EventEmitter {
|
||||
resize(sessionId: string, cols: number, rows: number, suppressOutput: boolean = true): boolean {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
console.warn(`[Terminal] Session ${sessionId} not found for resize`);
|
||||
logger.warn(`Session ${sessionId} not found for resize`);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
@@ -448,7 +449,7 @@ export class TerminalService extends EventEmitter {
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`[Terminal] Error resizing session ${sessionId}:`, error);
|
||||
logger.error(`Error resizing session ${sessionId}:`, error);
|
||||
session.resizeInProgress = false; // Clear flag on error
|
||||
return false;
|
||||
}
|
||||
@@ -476,14 +477,14 @@ export class TerminalService extends EventEmitter {
|
||||
}
|
||||
|
||||
// First try graceful SIGTERM to allow process cleanup
|
||||
console.log(`[Terminal] Session ${sessionId} sending SIGTERM`);
|
||||
logger.info(`Session ${sessionId} sending SIGTERM`);
|
||||
session.pty.kill('SIGTERM');
|
||||
|
||||
// Schedule SIGKILL fallback if process doesn't exit gracefully
|
||||
// The onExit handler will remove session from map when it actually exits
|
||||
setTimeout(() => {
|
||||
if (this.sessions.has(sessionId)) {
|
||||
console.log(`[Terminal] Session ${sessionId} still alive after SIGTERM, sending SIGKILL`);
|
||||
logger.info(`Session ${sessionId} still alive after SIGTERM, sending SIGKILL`);
|
||||
try {
|
||||
session.pty.kill('SIGKILL');
|
||||
} catch {
|
||||
@@ -494,10 +495,10 @@ export class TerminalService extends EventEmitter {
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
console.log(`[Terminal] Session ${sessionId} kill initiated`);
|
||||
logger.info(`Session ${sessionId} kill initiated`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`[Terminal] Error killing session ${sessionId}:`, error);
|
||||
logger.error(`Error killing session ${sessionId}:`, error);
|
||||
// Still try to remove from map even if kill fails
|
||||
this.sessions.delete(sessionId);
|
||||
return false;
|
||||
@@ -580,7 +581,7 @@ export class TerminalService extends EventEmitter {
|
||||
* Clean up all sessions
|
||||
*/
|
||||
cleanup(): void {
|
||||
console.log(`[Terminal] Cleaning up ${this.sessions.size} sessions`);
|
||||
logger.info(`Cleaning up ${this.sessions.size} sessions`);
|
||||
this.sessions.forEach((session, id) => {
|
||||
try {
|
||||
// Clean up flush timeout
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
export type {
|
||||
ThemeMode,
|
||||
KanbanCardDetailLevel,
|
||||
AgentModel,
|
||||
ModelAlias,
|
||||
PlanningMode,
|
||||
ThinkingLevel,
|
||||
ModelProvider,
|
||||
@@ -22,6 +22,9 @@ export type {
|
||||
BoardBackgroundSettings,
|
||||
WorktreeInfo,
|
||||
ProjectSettings,
|
||||
PhaseModelConfig,
|
||||
PhaseModelKey,
|
||||
PhaseModelEntry,
|
||||
} from '@automaker/types';
|
||||
|
||||
export {
|
||||
@@ -29,6 +32,7 @@ export {
|
||||
DEFAULT_GLOBAL_SETTINGS,
|
||||
DEFAULT_CREDENTIALS,
|
||||
DEFAULT_PROJECT_SETTINGS,
|
||||
DEFAULT_PHASE_MODELS,
|
||||
SETTINGS_VERSION,
|
||||
CREDENTIALS_VERSION,
|
||||
PROJECT_SETTINGS_VERSION,
|
||||
|
||||
@@ -56,20 +56,24 @@ describe('image-handler.ts', () => {
|
||||
});
|
||||
|
||||
describe('readImageAsBase64', () => {
|
||||
it('should read image and return base64 data', async () => {
|
||||
const mockBuffer = Buffer.from(pngBase64Fixture, 'base64');
|
||||
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
|
||||
// Skip on Windows as path.resolve converts Unix paths to Windows paths (CI runs on Linux)
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'should read image and return base64 data',
|
||||
async () => {
|
||||
const mockBuffer = Buffer.from(pngBase64Fixture, 'base64');
|
||||
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
|
||||
|
||||
const result = await readImageAsBase64('/path/to/test.png');
|
||||
const result = await readImageAsBase64('/path/to/test.png');
|
||||
|
||||
expect(result).toMatchObject({
|
||||
base64: pngBase64Fixture,
|
||||
mimeType: 'image/png',
|
||||
filename: 'test.png',
|
||||
originalPath: '/path/to/test.png',
|
||||
});
|
||||
expect(fs.readFile).toHaveBeenCalledWith('/path/to/test.png');
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
base64: pngBase64Fixture,
|
||||
mimeType: 'image/png',
|
||||
filename: 'test.png',
|
||||
originalPath: '/path/to/test.png',
|
||||
});
|
||||
expect(fs.readFile).toHaveBeenCalledWith('/path/to/test.png');
|
||||
}
|
||||
);
|
||||
|
||||
it('should handle different image formats', async () => {
|
||||
const mockBuffer = Buffer.from('jpeg-data');
|
||||
@@ -141,14 +145,18 @@ describe('image-handler.ts', () => {
|
||||
expect(calls[0][0]).toContain('dir');
|
||||
});
|
||||
|
||||
it('should handle absolute paths without workDir', async () => {
|
||||
const mockBuffer = Buffer.from('data');
|
||||
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
|
||||
// Skip on Windows as path.resolve converts Unix paths to Windows paths (CI runs on Linux)
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'should handle absolute paths without workDir',
|
||||
async () => {
|
||||
const mockBuffer = Buffer.from('data');
|
||||
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
|
||||
|
||||
await convertImagesToContentBlocks(['/absolute/path.png']);
|
||||
await convertImagesToContentBlocks(['/absolute/path.png']);
|
||||
|
||||
expect(fs.readFile).toHaveBeenCalledWith('/absolute/path.png');
|
||||
});
|
||||
expect(fs.readFile).toHaveBeenCalledWith('/absolute/path.png');
|
||||
}
|
||||
);
|
||||
|
||||
it('should continue processing on individual image errors', async () => {
|
||||
vi.mocked(fs.readFile)
|
||||
@@ -171,7 +179,8 @@ describe('image-handler.ts', () => {
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle undefined workDir', async () => {
|
||||
// Skip on Windows as path.resolve converts Unix paths to Windows paths (CI runs on Linux)
|
||||
it.skipIf(process.platform === 'win32')('should handle undefined workDir', async () => {
|
||||
const mockBuffer = Buffer.from('data');
|
||||
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
|
||||
|
||||
|
||||
308
apps/server/tests/unit/lib/json-extractor.test.ts
Normal file
308
apps/server/tests/unit/lib/json-extractor.test.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { extractJson, extractJsonWithKey, extractJsonWithArray } from '@/lib/json-extractor.js';
|
||||
|
||||
describe('json-extractor.ts', () => {
|
||||
const mockLogger = {
|
||||
debug: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('extractJson', () => {
|
||||
describe('Strategy 1: JSON in ```json code block', () => {
|
||||
it('should extract JSON from ```json code block', () => {
|
||||
const responseText = `Here is the result:
|
||||
\`\`\`json
|
||||
{"name": "test", "value": 42}
|
||||
\`\`\`
|
||||
That's all!`;
|
||||
|
||||
const result = extractJson(responseText, { logger: mockLogger });
|
||||
|
||||
expect(result).toEqual({ name: 'test', value: 42 });
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith('Extracting JSON from ```json code block');
|
||||
});
|
||||
|
||||
it('should handle multiline JSON in code block', () => {
|
||||
const responseText = `\`\`\`json
|
||||
{
|
||||
"items": [
|
||||
{"id": 1},
|
||||
{"id": 2}
|
||||
]
|
||||
}
|
||||
\`\`\``;
|
||||
|
||||
const result = extractJson(responseText, { logger: mockLogger });
|
||||
|
||||
expect(result).toEqual({ items: [{ id: 1 }, { id: 2 }] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Strategy 2: JSON in ``` code block (no language)', () => {
|
||||
it('should extract JSON from unmarked code block', () => {
|
||||
const responseText = `Result:
|
||||
\`\`\`
|
||||
{"status": "ok"}
|
||||
\`\`\``;
|
||||
|
||||
const result = extractJson(responseText, { logger: mockLogger });
|
||||
|
||||
expect(result).toEqual({ status: 'ok' });
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith('Extracting JSON from ``` code block');
|
||||
});
|
||||
|
||||
it('should handle array JSON in unmarked code block', () => {
|
||||
const responseText = `\`\`\`
|
||||
[1, 2, 3]
|
||||
\`\`\``;
|
||||
|
||||
const result = extractJson<number[]>(responseText, { logger: mockLogger });
|
||||
|
||||
expect(result).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('should skip non-JSON code blocks and find JSON via brace matching', () => {
|
||||
// When code block contains non-JSON, later strategies will try to extract
|
||||
// The first { in the response is in the function code, so brace matching
|
||||
// will try that and fail. The JSON after the code block is found via strategy 5.
|
||||
const responseText = `\`\`\`
|
||||
return true;
|
||||
\`\`\`
|
||||
Here is the JSON: {"actual": "json"}`;
|
||||
|
||||
const result = extractJson(responseText, { logger: mockLogger });
|
||||
|
||||
expect(result).toEqual({ actual: 'json' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Strategy 3: Find JSON with required key', () => {
|
||||
it('should find JSON containing required key', () => {
|
||||
const responseText = `Some text before {"features": ["a", "b"]} and after`;
|
||||
|
||||
const result = extractJson(responseText, {
|
||||
logger: mockLogger,
|
||||
requiredKey: 'features',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ features: ['a', 'b'] });
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith(
|
||||
'Extracting JSON with required key "features"'
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip JSON without required key', () => {
|
||||
const responseText = `{"wrong": "key"} {"features": ["correct"]}`;
|
||||
|
||||
const result = extractJson(responseText, {
|
||||
logger: mockLogger,
|
||||
requiredKey: 'features',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ features: ['correct'] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Strategy 4: Find any JSON by brace matching', () => {
|
||||
it('should extract JSON by matching braces', () => {
|
||||
const responseText = `Let me provide the response: {"result": "success", "data": {"nested": true}}. Done.`;
|
||||
|
||||
const result = extractJson(responseText, { logger: mockLogger });
|
||||
|
||||
expect(result).toEqual({ result: 'success', data: { nested: true } });
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith('Extracting JSON by brace matching');
|
||||
});
|
||||
|
||||
it('should handle deeply nested objects', () => {
|
||||
const responseText = `{"a": {"b": {"c": {"d": "deep"}}}}`;
|
||||
|
||||
const result = extractJson(responseText, { logger: mockLogger });
|
||||
|
||||
expect(result).toEqual({ a: { b: { c: { d: 'deep' } } } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Strategy 5: First { to last }', () => {
|
||||
it('should extract from first to last brace when other strategies fail', () => {
|
||||
// Create malformed JSON that brace matching fails but first-to-last works
|
||||
const responseText = `Prefix {"key": "value"} suffix text`;
|
||||
|
||||
const result = extractJson(responseText, { logger: mockLogger });
|
||||
|
||||
expect(result).toEqual({ key: 'value' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Strategy 6: Parse entire response as JSON', () => {
|
||||
it('should parse entire response when it is valid JSON object', () => {
|
||||
const responseText = `{"complete": "json"}`;
|
||||
|
||||
const result = extractJson(responseText, { logger: mockLogger });
|
||||
|
||||
expect(result).toEqual({ complete: 'json' });
|
||||
});
|
||||
|
||||
it('should parse entire response when it is valid JSON array', () => {
|
||||
const responseText = `["a", "b", "c"]`;
|
||||
|
||||
const result = extractJson<string[]>(responseText, { logger: mockLogger });
|
||||
|
||||
expect(result).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('should handle whitespace around JSON', () => {
|
||||
const responseText = `
|
||||
{"trimmed": true}
|
||||
`;
|
||||
|
||||
const result = extractJson(responseText, { logger: mockLogger });
|
||||
|
||||
expect(result).toEqual({ trimmed: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('requireArray option', () => {
|
||||
it('should validate required key contains array', () => {
|
||||
const responseText = `{"items": ["a", "b", "c"]}`;
|
||||
|
||||
const result = extractJson(responseText, {
|
||||
logger: mockLogger,
|
||||
requiredKey: 'items',
|
||||
requireArray: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ items: ['a', 'b', 'c'] });
|
||||
});
|
||||
|
||||
it('should reject when required key is not an array', () => {
|
||||
const responseText = `{"items": "not an array"}`;
|
||||
|
||||
const result = extractJson(responseText, {
|
||||
logger: mockLogger,
|
||||
requiredKey: 'items',
|
||||
requireArray: true,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should return null for invalid JSON', () => {
|
||||
const responseText = `This is not JSON at all`;
|
||||
|
||||
const result = extractJson(responseText, { logger: mockLogger });
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith('Failed to extract JSON from response');
|
||||
});
|
||||
|
||||
it('should return null for malformed JSON', () => {
|
||||
const responseText = `{"broken": }`;
|
||||
|
||||
const result = extractJson(responseText, { logger: mockLogger });
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for empty input', () => {
|
||||
const result = extractJson('', { logger: mockLogger });
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when required key is missing', () => {
|
||||
const responseText = `{"other": "key"}`;
|
||||
|
||||
const result = extractJson(responseText, {
|
||||
logger: mockLogger,
|
||||
requiredKey: 'missing',
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle JSON with escaped characters', () => {
|
||||
const responseText = `{"text": "Hello \\"World\\"", "path": "C:\\\\Users"}`;
|
||||
|
||||
const result = extractJson(responseText, { logger: mockLogger });
|
||||
|
||||
expect(result).toEqual({ text: 'Hello "World"', path: 'C:\\Users' });
|
||||
});
|
||||
|
||||
it('should handle JSON with unicode', () => {
|
||||
const responseText = `{"emoji": "🚀", "japanese": "日本語"}`;
|
||||
|
||||
const result = extractJson(responseText, { logger: mockLogger });
|
||||
|
||||
expect(result).toEqual({ emoji: '🚀', japanese: '日本語' });
|
||||
});
|
||||
|
||||
it('should work without custom logger', () => {
|
||||
const responseText = `{"simple": "test"}`;
|
||||
|
||||
const result = extractJson(responseText);
|
||||
|
||||
expect(result).toEqual({ simple: 'test' });
|
||||
});
|
||||
|
||||
it('should handle multiple JSON objects in text - takes first valid one', () => {
|
||||
const responseText = `First: {"a": 1} Second: {"b": 2}`;
|
||||
|
||||
const result = extractJson(responseText, { logger: mockLogger });
|
||||
|
||||
expect(result).toEqual({ a: 1 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractJsonWithKey', () => {
|
||||
it('should extract JSON with specified required key', () => {
|
||||
const responseText = `{"suggestions": [{"title": "Test"}]}`;
|
||||
|
||||
const result = extractJsonWithKey(responseText, 'suggestions', { logger: mockLogger });
|
||||
|
||||
expect(result).toEqual({ suggestions: [{ title: 'Test' }] });
|
||||
});
|
||||
|
||||
it('should return null when key is missing', () => {
|
||||
const responseText = `{"other": "data"}`;
|
||||
|
||||
const result = extractJsonWithKey(responseText, 'suggestions', { logger: mockLogger });
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractJsonWithArray', () => {
|
||||
it('should extract JSON with array at specified key', () => {
|
||||
const responseText = `{"features": ["feature1", "feature2"]}`;
|
||||
|
||||
const result = extractJsonWithArray(responseText, 'features', { logger: mockLogger });
|
||||
|
||||
expect(result).toEqual({ features: ['feature1', 'feature2'] });
|
||||
});
|
||||
|
||||
it('should return null when key value is not an array', () => {
|
||||
const responseText = `{"features": "not an array"}`;
|
||||
|
||||
const result = extractJsonWithArray(responseText, 'features', { logger: mockLogger });
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when key is missing', () => {
|
||||
const responseText = `{"other": ["array"]}`;
|
||||
|
||||
const result = extractJsonWithArray(responseText, 'features', { logger: mockLogger });
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user