mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
Compare commits
392 Commits
v0.11.0
...
2f883bad20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f883bad20 | ||
|
|
45706990df | ||
|
|
c9c406dd21 | ||
|
|
014736bc1d | ||
|
|
c05359c787 | ||
|
|
a32cb08d1e | ||
|
|
08d1497cbe | ||
|
|
5c335641fa | ||
|
|
0fb471ca15 | ||
|
|
b65037d995 | ||
|
|
5eda2c9b2b | ||
|
|
006152554b | ||
|
|
3b56d553c9 | ||
|
|
375f9ea9d4 | ||
|
|
bf25a7a4e5 | ||
|
|
5171abc37f | ||
|
|
9c8265c4e5 | ||
|
|
ef779daedf | ||
|
|
011ac404bb | ||
|
|
9587f13de5 | ||
|
|
08dc90b378 | ||
|
|
80ef21c8d0 | ||
|
|
98d98cc056 | ||
|
|
2a24377870 | ||
|
|
895e4c28ba | ||
|
|
ebf2fcadd6 | ||
|
|
019da6b77a | ||
|
|
605d9658d9 | ||
|
|
906f471521 | ||
|
|
a10ddadbde | ||
|
|
3399d48823 | ||
|
|
7f5c5e864d | ||
|
|
35d2d41821 | ||
|
|
6a3993385e | ||
|
|
df7024f4ea | ||
|
|
4485c49c9b | ||
|
|
7a5cb38a37 | ||
|
|
c9833b67a0 | ||
|
|
0f11ee2212 | ||
|
|
74b301c2d1 | ||
|
|
81ee2d1399 | ||
|
|
f025ced035 | ||
|
|
4f07948712 | ||
|
|
07f95ae13b | ||
|
|
8dd6ab2161 | ||
|
|
b5143f4b00 | ||
|
|
f5efa857ca | ||
|
|
c401bf4e63 | ||
|
|
43d5ec9aed | ||
|
|
f8108b1a6c | ||
|
|
076ab14a5e | ||
|
|
a4c43b99a5 | ||
|
|
0f00180c50 | ||
|
|
22853c988a | ||
|
|
e52837cbe7 | ||
|
|
d12e0705f0 | ||
|
|
a3e536b8e6 | ||
|
|
43661e5a6e | ||
|
|
1b2bf0df3f | ||
|
|
b1060c6a11 | ||
|
|
db87e83aed | ||
|
|
92b1fb3725 | ||
|
|
d7f86d142a | ||
|
|
bbe669cdf2 | ||
|
|
8e13245aab | ||
|
|
cec5f91a86 | ||
|
|
ed92d4fd80 | ||
|
|
a6190f71b3 | ||
|
|
d04934359a | ||
|
|
7246debb69 | ||
|
|
066ffe5639 | ||
|
|
7bf02b64fa | ||
|
|
a3c62e8358 | ||
|
|
1ecb97b71c | ||
|
|
1e87b73dfd | ||
|
|
5a3dac1533 | ||
|
|
f3b16ad8ce | ||
|
|
140c444e6f | ||
|
|
907c1d65b3 | ||
|
|
92f2702f3b | ||
|
|
735786701f | ||
|
|
900bbb5e80 | ||
|
|
bc3e3dad1c | ||
|
|
d8fa5c4cd1 | ||
|
|
f005c30017 | ||
|
|
4012a2964a | ||
|
|
0b92349890 | ||
|
|
51a75ae589 | ||
|
|
650edd69ca | ||
|
|
46abd34444 | ||
|
|
5cf817e9de | ||
|
|
42ee4f211d | ||
|
|
372cfe6982 | ||
|
|
1430fb6926 | ||
|
|
9e15f3609a | ||
|
|
b34ffd9565 | ||
|
|
ac9f33bd2b | ||
|
|
269b1c9478 | ||
|
|
7bc7918cc6 | ||
|
|
860d6836b9 | ||
|
|
5281b81ddf | ||
|
|
7a33940816 | ||
|
|
ee4464bdad | ||
|
|
7e1095b773 | ||
|
|
9d297c650a | ||
|
|
68d78f2f5b | ||
|
|
fb6d6bbf2f | ||
|
|
c8ed3fafce | ||
|
|
5939c5d20b | ||
|
|
ad6fc01045 | ||
|
|
ea34f304cb | ||
|
|
53ad78dfc8 | ||
|
|
26b819291f | ||
|
|
01859f3a9a | ||
|
|
a4214276d7 | ||
|
|
d09da4af20 | ||
|
|
afb6e14811 | ||
|
|
c65f931326 | ||
|
|
f480386905 | ||
|
|
7773db559d | ||
|
|
655f254538 | ||
|
|
b4be3c11e2 | ||
|
|
433e6016c3 | ||
|
|
02dfda108e | ||
|
|
57ce198ae9 | ||
|
|
733ca15e15 | ||
|
|
e110c058a2 | ||
|
|
0fdda11b09 | ||
|
|
0155da0be5 | ||
|
|
41b127ebf3 | ||
|
|
e7e83a30d9 | ||
|
|
40950b5fce | ||
|
|
3f05735be1 | ||
|
|
05f0ceceb6 | ||
|
|
28d50aa017 | ||
|
|
103c6bc8a0 | ||
|
|
6c47068f71 | ||
|
|
a9616ff309 | ||
|
|
4fa0923ff8 | ||
|
|
c3cecc18f2 | ||
|
|
3fcda8abfc | ||
|
|
a45ee59b7d | ||
|
|
662f854203 | ||
|
|
f2860d9366 | ||
|
|
6eb7acb6d4 | ||
|
|
4ab927a5fb | ||
|
|
02de3df3df | ||
|
|
b73885e04a | ||
|
|
afa93dde0d | ||
|
|
aac59c2b3a | ||
|
|
c3e7e57968 | ||
|
|
c55654b737 | ||
|
|
7bb97953a7 | ||
|
|
2214c2700b | ||
|
|
7bee54717c | ||
|
|
5ab53afd7f | ||
|
|
3ebd67f35f | ||
|
|
641bbde877 | ||
|
|
7c80249bbf | ||
|
|
a73a57b9a4 | ||
|
|
db71dc9aa5 | ||
|
|
a8ddd07442 | ||
|
|
2165223b49 | ||
|
|
3bde3d2732 | ||
|
|
900a312c92 | ||
|
|
69ff8df7c1 | ||
|
|
4f584f9a89 | ||
|
|
47a6033b43 | ||
|
|
a1f234c7e2 | ||
|
|
8facdc66a9 | ||
|
|
2ab78dd590 | ||
|
|
c14a40f7f8 | ||
|
|
8dd5858299 | ||
|
|
76eb3a2ac2 | ||
|
|
179c5ae9c2 | ||
|
|
8c356d7c36 | ||
|
|
a863dcc11d | ||
|
|
cf60f84f89 | ||
|
|
47e6ed6a17 | ||
|
|
d266c98e48 | ||
|
|
628e464b74 | ||
|
|
17d42e7931 | ||
|
|
5119ee4222 | ||
|
|
b039b745be | ||
|
|
02a7a54736 | ||
|
|
43481c2bab | ||
|
|
d7f6e72a9e | ||
|
|
82e22b4362 | ||
|
|
0d9259473e | ||
|
|
ea3930cf3d | ||
|
|
d97c4b7b57 | ||
|
|
2fac2ca4bb | ||
|
|
9bb52f1ded | ||
|
|
f987fc1f10 | ||
|
|
63b8eb0991 | ||
|
|
a52c0461e5 | ||
|
|
e73c92b031 | ||
|
|
09151aa3c8 | ||
|
|
d6300f33ca | ||
|
|
4b0d1399b1 | ||
|
|
55a34a9f1f | ||
|
|
c4652190eb | ||
|
|
af95dae73a | ||
|
|
1c1d9d30a7 | ||
|
|
3faebfa3fe | ||
|
|
d0eaf0e51d | ||
|
|
cf3ee6aec6 | ||
|
|
da80729f56 | ||
|
|
9ad58e1a74 | ||
|
|
55b17a7a11 | ||
|
|
2854e24e84 | ||
|
|
b91d84ee84 | ||
|
|
30a2c3d740 | ||
|
|
e3213b1426 | ||
|
|
bfc23cdfa1 | ||
|
|
8b5da3195b | ||
|
|
0c452a3ebc | ||
|
|
cfc5530d1c | ||
|
|
749fb3a5c1 | ||
|
|
dd26de9f55 | ||
|
|
b6cb926cbe | ||
|
|
eb30ef71f9 | ||
|
|
75fe579e93 | ||
|
|
8ab9dc5a11 | ||
|
|
96202d4bc2 | ||
|
|
f68aee6a19 | ||
|
|
7795d81183 | ||
|
|
0c053dab48 | ||
|
|
1ede7e7e6a | ||
|
|
980006d40e | ||
|
|
ef2dcbacd4 | ||
|
|
505a2b1e0b | ||
|
|
2e57553639 | ||
|
|
f37812247d | ||
|
|
484d4c65d5 | ||
|
|
327aef89a2 | ||
|
|
d96f369b73 | ||
|
|
f0e655f49a | ||
|
|
d22deabe79 | ||
|
|
518c81815e | ||
|
|
01652d0d11 | ||
|
|
44e665f1bf | ||
|
|
5b1e0105f4 | ||
|
|
832d10e133 | ||
|
|
7b7ac72c14 | ||
|
|
9137f0e75f | ||
|
|
b66efae5b7 | ||
|
|
2a8706e714 | ||
|
|
174c02cb79 | ||
|
|
a7f7898ee4 | ||
|
|
fdad82bf88 | ||
|
|
b0b49764b9 | ||
|
|
e10cb83adc | ||
|
|
b8875f71a5 | ||
|
|
4186b80a82 | ||
|
|
7eae0215f2 | ||
|
|
4cd84a4734 | ||
|
|
044c3d50d1 | ||
|
|
a1de0a78a0 | ||
|
|
fef9639e01 | ||
|
|
aef479218d | ||
|
|
ded5ecf4e9 | ||
|
|
a01f299597 | ||
|
|
21c9e88a86 | ||
|
|
af17f6e36f | ||
|
|
e69a2ad722 | ||
|
|
0480f6ccd6 | ||
|
|
24042d20c2 | ||
|
|
9c3b3a4104 | ||
|
|
17e2cdfc85 | ||
|
|
466c34afd4 | ||
|
|
b9567f5904 | ||
|
|
c2cf8ae892 | ||
|
|
3aa3c10ea4 | ||
|
|
5cd4183a7b | ||
|
|
2d9e38ad99 | ||
|
|
93d73f6d26 | ||
|
|
5209395a74 | ||
|
|
ef6b9ac2d2 | ||
|
|
92afbeb6bd | ||
|
|
bbdc11ce47 | ||
|
|
545bf2045d | ||
|
|
a0471098fa | ||
|
|
3320b40d15 | ||
|
|
bac5e1c220 | ||
|
|
33fa138d21 | ||
|
|
bc09a22e1f | ||
|
|
b771b51842 | ||
|
|
1a7bf27ead | ||
|
|
f3b00d0f78 | ||
|
|
c747baaee2 | ||
|
|
1322722db2 | ||
|
|
aa35eb3d3a | ||
|
|
616e2ef75f | ||
|
|
d98cae124f | ||
|
|
26aaef002d | ||
|
|
09bb59d090 | ||
|
|
2f38ffe2d5 | ||
|
|
12fa9d858d | ||
|
|
c4e1a58e0d | ||
|
|
8661f33c6d | ||
|
|
5c24ca2220 | ||
|
|
14559354dd | ||
|
|
3bf9dbd43a | ||
|
|
bd3999416b | ||
|
|
cc9f7d48c8 | ||
|
|
6bb0461be7 | ||
|
|
16ef026b38 | ||
|
|
50ed405c4a | ||
|
|
5407e1a9ff | ||
|
|
5436b18f70 | ||
|
|
8b7700364d | ||
|
|
3bdf3cbb5c | ||
|
|
45d9c9a5d8 | ||
|
|
6a23e6ce78 | ||
|
|
4e53215104 | ||
|
|
2899b6d416 | ||
|
|
b263cc615e | ||
|
|
97b0028919 | ||
|
|
fd1727a443 | ||
|
|
597cb9bfae | ||
|
|
c2430e5bd3 | ||
|
|
68df8efd10 | ||
|
|
c0d64bc994 | ||
|
|
6237f1a0fe | ||
|
|
30c50d9b78 | ||
|
|
03516ac09e | ||
|
|
5e5a136f1f | ||
|
|
98c50d44a4 | ||
|
|
0e9369816f | ||
|
|
be63a59e9c | ||
|
|
dbb84aba23 | ||
|
|
9819d2e91c | ||
|
|
4c24ba5a8b | ||
|
|
e67cab1e07 | ||
|
|
132b8f7529 | ||
|
|
d651e9d8d6 | ||
|
|
92f14508aa | ||
|
|
842b059fac | ||
|
|
49f9ecc168 | ||
|
|
e02fd889c2 | ||
|
|
52a821d3bb | ||
|
|
becd79f1e3 | ||
|
|
883ad2a04b | ||
|
|
bf93cdf0c4 | ||
|
|
c0ea1c736a | ||
|
|
8b448b9481 | ||
|
|
12f2b9f2b3 | ||
|
|
017ff3ca0a | ||
|
|
bcec178bbe | ||
|
|
e3347c7b9c | ||
|
|
6529446281 | ||
|
|
379551c40e | ||
|
|
7465017600 | ||
|
|
874c5a36de | ||
|
|
03436103d1 | ||
|
|
cb544e0011 | ||
|
|
df23c9e6ab | ||
|
|
52cc82fb3f | ||
|
|
d9571bfb8d | ||
|
|
07d800b589 | ||
|
|
ec042de69c | ||
|
|
585ae32c32 | ||
|
|
a89ba04109 | ||
|
|
05a3b95d75 | ||
|
|
0e269ca15d | ||
|
|
fd03cb4afa | ||
|
|
d6c5c93fe5 | ||
|
|
1abf219230 | ||
|
|
3a2ba6dbfe | ||
|
|
8fa8ba0a16 | ||
|
|
285f526e0c | ||
|
|
bd68b497ac | ||
|
|
06b047cfcb | ||
|
|
361cb06bf0 | ||
|
|
3170e22383 | ||
|
|
c585cee12f | ||
|
|
9dbec7281a | ||
|
|
c2fed78733 | ||
|
|
5fe7bcd378 | ||
|
|
20caa424fc | ||
|
|
c4e0a7cc96 | ||
|
|
d1219a225c | ||
|
|
3411256366 | ||
|
|
d08ef472a3 | ||
|
|
d81997d24b | ||
|
|
845674128e | ||
|
|
2bc931a8b0 | ||
|
|
e57549c06e | ||
|
|
241fd0b252 | ||
|
|
164acc1b4e | ||
|
|
927ce9121d |
3
.github/actions/setup-project/action.yml
vendored
3
.github/actions/setup-project/action.yml
vendored
@@ -41,7 +41,8 @@ runs:
|
||||
# Use npm install instead of npm ci to correctly resolve platform-specific
|
||||
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
|
||||
# Skip scripts to avoid electron-builder install-app-deps which uses too much memory
|
||||
run: npm install --ignore-scripts
|
||||
# Use --force to allow platform-specific dev dependencies like dmg-license on non-darwin platforms
|
||||
run: npm install --ignore-scripts --force
|
||||
|
||||
- name: Install Linux native bindings
|
||||
shell: bash
|
||||
|
||||
2
.github/workflows/format-check.yml
vendored
2
.github/workflows/format-check.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install --ignore-scripts
|
||||
run: npm install --ignore-scripts --force
|
||||
|
||||
- name: Check formatting
|
||||
run: npm run format:check
|
||||
|
||||
30
.github/workflows/release.yml
vendored
30
.github/workflows/release.yml
vendored
@@ -4,6 +4,9 @@ on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
@@ -35,6 +38,11 @@ jobs:
|
||||
with:
|
||||
check-lockfile: 'true'
|
||||
|
||||
- name: Install RPM build tools (Linux)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
shell: bash
|
||||
run: sudo apt-get update && sudo apt-get install -y rpm
|
||||
|
||||
- name: Build Electron app (macOS)
|
||||
if: matrix.os == 'macos-latest'
|
||||
shell: bash
|
||||
@@ -57,7 +65,10 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos-builds
|
||||
path: apps/ui/release/*.{dmg,zip}
|
||||
path: |
|
||||
apps/ui/release/*.dmg
|
||||
apps/ui/release/*.zip
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Windows artifacts
|
||||
@@ -66,6 +77,7 @@ jobs:
|
||||
with:
|
||||
name: windows-builds
|
||||
path: apps/ui/release/*.exe
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Linux artifacts
|
||||
@@ -73,7 +85,11 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-builds
|
||||
path: apps/ui/release/*.{AppImage,deb}
|
||||
path: |
|
||||
apps/ui/release/*.AppImage
|
||||
apps/ui/release/*.deb
|
||||
apps/ui/release/*.rpm
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
upload:
|
||||
@@ -103,9 +119,13 @@ jobs:
|
||||
- name: Upload to GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
fail_on_unmatched_files: true
|
||||
files: |
|
||||
artifacts/macos-builds/*
|
||||
artifacts/windows-builds/*
|
||||
artifacts/linux-builds/*
|
||||
artifacts/macos-builds/*.dmg
|
||||
artifacts/macos-builds/*.zip
|
||||
artifacts/windows-builds/*.exe
|
||||
artifacts/linux-builds/*.AppImage
|
||||
artifacts/linux-builds/*.deb
|
||||
artifacts/linux-builds/*.rpm
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -166,7 +166,11 @@ Use `resolveModelString()` from `@automaker/model-resolver` to convert model ali
|
||||
## Environment Variables
|
||||
|
||||
- `ANTHROPIC_API_KEY` - Anthropic API key (or use Claude Code CLI auth)
|
||||
- `HOST` - Host to bind server to (default: 0.0.0.0)
|
||||
- `HOSTNAME` - Hostname for user-facing URLs (default: localhost)
|
||||
- `PORT` - Server port (default: 3008)
|
||||
- `DATA_DIR` - Data storage directory (default: ./data)
|
||||
- `ALLOWED_ROOT_DIRECTORY` - Restrict file operations to specific directory
|
||||
- `AUTOMAKER_MOCK_AGENT=true` - Enable mock agent mode for CI testing
|
||||
- `AUTOMAKER_AUTO_LOGIN=true` - Skip login prompt in development (disabled when NODE_ENV=production)
|
||||
- `VITE_HOSTNAME` - Hostname for frontend API URLs (default: localhost)
|
||||
|
||||
@@ -25,9 +25,11 @@ COPY libs/types/package*.json ./libs/types/
|
||||
COPY libs/utils/package*.json ./libs/utils/
|
||||
COPY libs/prompts/package*.json ./libs/prompts/
|
||||
COPY libs/platform/package*.json ./libs/platform/
|
||||
COPY libs/spec-parser/package*.json ./libs/spec-parser/
|
||||
COPY libs/model-resolver/package*.json ./libs/model-resolver/
|
||||
COPY libs/dependency-resolver/package*.json ./libs/dependency-resolver/
|
||||
COPY libs/git-utils/package*.json ./libs/git-utils/
|
||||
COPY libs/spec-parser/package*.json ./libs/spec-parser/
|
||||
|
||||
# Copy scripts (needed by npm workspace)
|
||||
COPY scripts ./scripts
|
||||
|
||||
160
README.md
160
README.md
@@ -28,6 +28,7 @@
|
||||
- [Quick Start](#quick-start)
|
||||
- [How to Run](#how-to-run)
|
||||
- [Development Mode](#development-mode)
|
||||
- [Interactive TUI Launcher](#interactive-tui-launcher-recommended-for-new-users)
|
||||
- [Building for Production](#building-for-production)
|
||||
- [Testing](#testing)
|
||||
- [Linting](#linting)
|
||||
@@ -101,11 +102,9 @@ In the Discord, you can:
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Node.js 18+** (tested with Node.js 22)
|
||||
- **Node.js 22+** (required: >=22.0.0 <23.0.0)
|
||||
- **npm** (comes with Node.js)
|
||||
- **Authentication** (choose one):
|
||||
- **[Claude Code CLI](https://code.claude.com/docs/en/overview)** (recommended) - Install and authenticate, credentials used automatically
|
||||
- **Anthropic API Key** - Direct API key for Claude Agent SDK ([get one here](https://console.anthropic.com/))
|
||||
- **[Claude Code CLI](https://code.claude.com/docs/en/overview)** - Install and authenticate with your Anthropic subscription. Automaker integrates with your authenticated Claude Code CLI to access Claude models.
|
||||
|
||||
### Quick Start
|
||||
|
||||
@@ -117,30 +116,14 @@ cd automaker
|
||||
# 2. Install dependencies
|
||||
npm install
|
||||
|
||||
# 3. Build shared packages (can be skipped - npm run dev does it automatically)
|
||||
npm run build:packages
|
||||
|
||||
# 4. Start Automaker
|
||||
# 3. Start Automaker
|
||||
npm run dev
|
||||
# Choose between:
|
||||
# 1. Web Application (browser at localhost:3007)
|
||||
# 2. Desktop Application (Electron - recommended)
|
||||
```
|
||||
|
||||
**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
|
||||
```
|
||||
**Authentication:** Automaker integrates with your authenticated Claude Code CLI. Make sure you have [installed and authenticated](https://code.claude.com/docs/en/quickstart) the Claude Code CLI before running Automaker. Your CLI credentials will be detected automatically.
|
||||
|
||||
**For Development:** `npm run dev` starts the development server with Vite live reload and hot module replacement for fast refresh and instant updates as you make changes.
|
||||
|
||||
@@ -179,6 +162,40 @@ npm run dev:electron:wsl:gpu
|
||||
npm run dev:web
|
||||
```
|
||||
|
||||
### Interactive TUI Launcher (Recommended for New Users)
|
||||
|
||||
For a user-friendly interactive menu, use the built-in TUI launcher script:
|
||||
|
||||
```bash
|
||||
# Show interactive menu with all launch options
|
||||
./start-automaker.sh
|
||||
|
||||
# Or launch directly without menu
|
||||
./start-automaker.sh web # Web browser
|
||||
./start-automaker.sh electron # Desktop app
|
||||
./start-automaker.sh electron-debug # Desktop + DevTools
|
||||
|
||||
# Additional options
|
||||
./start-automaker.sh --help # Show all available options
|
||||
./start-automaker.sh --version # Show version information
|
||||
./start-automaker.sh --check-deps # Verify project dependencies
|
||||
./start-automaker.sh --no-colors # Disable colored output
|
||||
./start-automaker.sh --no-history # Don't remember last choice
|
||||
```
|
||||
|
||||
**Features:**
|
||||
|
||||
- 🎨 Beautiful terminal UI with gradient colors and ASCII art
|
||||
- ⌨️ Interactive menu (press 1-3 to select, Q to exit)
|
||||
- 💾 Remembers your last choice
|
||||
- ✅ Pre-flight checks (validates Node.js, npm, dependencies)
|
||||
- 📏 Responsive layout (adapts to terminal size)
|
||||
- ⏱️ 30-second timeout for hands-free selection
|
||||
- 🌐 Cross-shell compatible (bash/zsh)
|
||||
|
||||
**History File:**
|
||||
Your last selected mode is saved in `~/.automaker_launcher_history` for quick re-runs.
|
||||
|
||||
### Building for Production
|
||||
|
||||
#### Web Application
|
||||
@@ -197,11 +214,30 @@ npm run build:electron
|
||||
# Platform-specific builds
|
||||
npm run build:electron:mac # macOS (DMG + ZIP, x64 + arm64)
|
||||
npm run build:electron:win # Windows (NSIS installer, x64)
|
||||
npm run build:electron:linux # Linux (AppImage + DEB, x64)
|
||||
npm run build:electron:linux # Linux (AppImage + DEB + RPM, x64)
|
||||
|
||||
# Output directory: apps/ui/release/
|
||||
```
|
||||
|
||||
**Linux Distribution Packages:**
|
||||
|
||||
- **AppImage**: Universal format, works on any Linux distribution
|
||||
- **DEB**: Ubuntu, Debian, Linux Mint, Pop!\_OS
|
||||
- **RPM**: Fedora, RHEL, Rocky Linux, AlmaLinux, openSUSE
|
||||
|
||||
**Installing on Fedora/RHEL:**
|
||||
|
||||
```bash
|
||||
# Download the RPM package
|
||||
wget https://github.com/AutoMaker-Org/automaker/releases/latest/download/Automaker-<version>-x86_64.rpm
|
||||
|
||||
# Install with dnf (Fedora)
|
||||
sudo dnf install ./Automaker-<version>-x86_64.rpm
|
||||
|
||||
# Or with yum (RHEL/CentOS)
|
||||
sudo yum localinstall ./Automaker-<version>-x86_64.rpm
|
||||
```
|
||||
|
||||
#### Docker Deployment
|
||||
|
||||
Docker provides the most secure way to run Automaker by isolating it from your host filesystem.
|
||||
@@ -220,16 +256,9 @@ docker-compose logs -f
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
##### Configuration
|
||||
##### Authentication
|
||||
|
||||
Create a `.env` file in the project root if using API key authentication:
|
||||
|
||||
```bash
|
||||
# Optional: Anthropic API key (not needed if using Claude CLI authentication)
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
```
|
||||
|
||||
**Note:** Most users authenticate via Claude CLI instead of API keys. See [Claude CLI Authentication](#claude-cli-authentication-optional) below.
|
||||
Automaker integrates with your authenticated Claude Code CLI. To use CLI authentication in Docker, mount your Claude CLI config directory (see [Claude CLI Authentication](#claude-cli-authentication) below).
|
||||
|
||||
##### Working with Projects (Host Directory Access)
|
||||
|
||||
@@ -243,9 +272,9 @@ services:
|
||||
- /path/to/your/project:/projects/your-project
|
||||
```
|
||||
|
||||
##### Claude CLI Authentication (Optional)
|
||||
##### Claude CLI Authentication
|
||||
|
||||
To use Claude Code CLI authentication instead of an API key, mount your Claude CLI config directory:
|
||||
Mount your Claude CLI config directory to use your authenticated CLI credentials:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -343,10 +372,6 @@ npm run lint
|
||||
|
||||
### Environment Configuration
|
||||
|
||||
#### Authentication (if not using Claude Code CLI)
|
||||
|
||||
- `ANTHROPIC_API_KEY` - Your Anthropic API key for Claude Agent SDK (not needed if using Claude Code CLI)
|
||||
|
||||
#### Optional - Server
|
||||
|
||||
- `PORT` - Server port (default: 3008)
|
||||
@@ -357,49 +382,23 @@ npm run lint
|
||||
|
||||
- `AUTOMAKER_API_KEY` - Optional API authentication for the server
|
||||
- `ALLOWED_ROOT_DIRECTORY` - Restrict file operations to specific directory
|
||||
- `CORS_ORIGIN` - CORS policy (default: \*)
|
||||
- `CORS_ORIGIN` - CORS allowed origins (comma-separated list; defaults to localhost only)
|
||||
|
||||
#### Optional - Development
|
||||
|
||||
- `VITE_SKIP_ELECTRON` - Skip Electron in dev mode
|
||||
- `OPEN_DEVTOOLS` - Auto-open DevTools in Electron
|
||||
- `AUTOMAKER_SKIP_SANDBOX_WARNING` - Skip sandbox warning dialog (useful for dev/CI)
|
||||
- `AUTOMAKER_AUTO_LOGIN=true` - Skip login prompt in development (ignored when NODE_ENV=production)
|
||||
|
||||
### Authentication Setup
|
||||
|
||||
#### Option 1: Claude Code CLI (Recommended)
|
||||
Automaker integrates with your authenticated Claude Code CLI and uses your Anthropic subscription.
|
||||
|
||||
Install and authenticate the Claude Code CLI following the [official quickstart guide](https://code.claude.com/docs/en/quickstart).
|
||||
|
||||
Once authenticated, Automaker will automatically detect and use your CLI credentials. No additional configuration needed!
|
||||
|
||||
#### Option 2: Direct API Key
|
||||
|
||||
If you prefer not to use the CLI, you can provide an Anthropic API key directly using one of these methods:
|
||||
|
||||
##### 2a. Shell Configuration
|
||||
|
||||
Add to your `~/.bashrc` or `~/.zshrc`:
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_API_KEY="sk-ant-..."
|
||||
```
|
||||
|
||||
Then restart your terminal or run `source ~/.bashrc` (or `source ~/.zshrc`).
|
||||
|
||||
##### 2b. .env File
|
||||
|
||||
Create a `.env` file in the project root (gitignored):
|
||||
|
||||
```bash
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
PORT=3008
|
||||
DATA_DIR=./data
|
||||
```
|
||||
|
||||
##### 2c. In-App Storage
|
||||
|
||||
The application can store your API key securely in the settings UI. The key is persisted in the `DATA_DIR` directory.
|
||||
|
||||
## Features
|
||||
|
||||
### Core Workflow
|
||||
@@ -508,20 +507,24 @@ Automaker provides several specialized views accessible via the sidebar or keybo
|
||||
| **Agent** | `A` | Interactive chat sessions with AI agents for exploratory work and questions |
|
||||
| **Spec** | `D` | Project specification editor with AI-powered generation and feature suggestions |
|
||||
| **Context** | `C` | Manage context files (markdown, images) that AI agents automatically reference |
|
||||
| **Profiles** | `M` | Create and manage AI agent profiles with custom prompts and configurations |
|
||||
| **Settings** | `S` | Configure themes, shortcuts, defaults, authentication, and more |
|
||||
| **Terminal** | `T` | Integrated terminal with tabs, splits, and persistent sessions |
|
||||
| **GitHub Issues** | - | Import and validate GitHub issues, convert to tasks |
|
||||
| **Graph** | `H` | Visualize feature dependencies with interactive graph visualization |
|
||||
| **Ideation** | `I` | Brainstorm and generate ideas with AI assistance |
|
||||
| **Memory** | `Y` | View and manage agent memory and conversation history |
|
||||
| **GitHub Issues** | `G` | Import and validate GitHub issues, convert to tasks |
|
||||
| **GitHub PRs** | `R` | View and manage GitHub pull requests |
|
||||
| **Running Agents** | - | View all active agents across projects with status and progress |
|
||||
|
||||
### Keyboard Navigation
|
||||
|
||||
All shortcuts are customizable in Settings. Default shortcuts:
|
||||
|
||||
- **Navigation:** `K` (Board), `A` (Agent), `D` (Spec), `C` (Context), `S` (Settings), `M` (Profiles), `T` (Terminal)
|
||||
- **Navigation:** `K` (Board), `A` (Agent), `D` (Spec), `C` (Context), `S` (Settings), `T` (Terminal), `H` (Graph), `I` (Ideation), `Y` (Memory), `G` (GitHub Issues), `R` (GitHub PRs)
|
||||
- **UI:** `` ` `` (Toggle sidebar)
|
||||
- **Actions:** `N` (New item in current view), `G` (Start next features), `O` (Open project), `P` (Project picker)
|
||||
- **Actions:** `N` (New item in current view), `O` (Open project), `P` (Project picker)
|
||||
- **Projects:** `Q`/`E` (Cycle previous/next project)
|
||||
- **Terminal:** `Alt+D` (Split right), `Alt+S` (Split down), `Alt+W` (Close), `Alt+T` (New tab)
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -586,10 +589,16 @@ Stored in `{projectPath}/.automaker/`:
|
||||
│ ├── agent-output.md # AI agent output log
|
||||
│ └── images/ # Attached images
|
||||
├── context/ # Context files for AI agents
|
||||
├── worktrees/ # Git worktree metadata
|
||||
├── validations/ # GitHub issue validation results
|
||||
├── ideation/ # Brainstorming and analysis data
|
||||
│ └── analysis.json # Project structure analysis
|
||||
├── board/ # Board-related data
|
||||
├── images/ # Project-level images
|
||||
├── settings.json # Project-specific settings
|
||||
├── spec.md # Project specification
|
||||
├── analysis.json # Project structure analysis
|
||||
└── feature-suggestions.json # AI-generated suggestions
|
||||
├── app_spec.txt # Project specification (XML format)
|
||||
├── active-branches.json # Active git branches tracking
|
||||
└── execution-state.json # Auto-mode execution state
|
||||
```
|
||||
|
||||
#### Global Data
|
||||
@@ -627,7 +636,6 @@ data/
|
||||
|
||||
- [Contributing Guide](./CONTRIBUTING.md) - How to contribute to Automaker
|
||||
- [Project Documentation](./docs/) - Architecture guides, patterns, and developer docs
|
||||
- [Docker Isolation Guide](./docs/docker-isolation.md) - Security-focused Docker deployment
|
||||
- [Shared Packages Guide](./docs/llm-shared-packages.md) - Using monorepo packages
|
||||
|
||||
### Community
|
||||
|
||||
17
TODO.md
17
TODO.md
@@ -1,17 +0,0 @@
|
||||
# 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.
|
||||
@@ -44,6 +44,11 @@ CORS_ORIGIN=http://localhost:3007
|
||||
# OPTIONAL - Server
|
||||
# ============================================
|
||||
|
||||
# Host to bind the server to (default: 0.0.0.0)
|
||||
# Use 0.0.0.0 to listen on all interfaces (recommended for Docker/remote access)
|
||||
# Use 127.0.0.1 or localhost to restrict to local connections only
|
||||
HOST=0.0.0.0
|
||||
|
||||
# Port to run the server on
|
||||
PORT=3008
|
||||
|
||||
@@ -63,6 +68,14 @@ TERMINAL_PASSWORD=
|
||||
|
||||
ENABLE_REQUEST_LOGGING=false
|
||||
|
||||
# ============================================
|
||||
# OPTIONAL - UI Behavior
|
||||
# ============================================
|
||||
|
||||
# Skip the sandbox warning dialog on startup (default: false)
|
||||
# Set to "true" to disable the warning entirely (useful for dev/CI environments)
|
||||
AUTOMAKER_SKIP_SANDBOX_WARNING=false
|
||||
|
||||
# ============================================
|
||||
# OPTIONAL - Debugging
|
||||
# ============================================
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@automaker/server",
|
||||
"version": "0.11.0",
|
||||
"version": "0.13.0",
|
||||
"description": "Backend server for Automaker - provides API for both web and Electron modes",
|
||||
"author": "AutoMaker Team",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
@@ -32,6 +32,7 @@
|
||||
"@automaker/prompts": "1.0.0",
|
||||
"@automaker/types": "1.0.0",
|
||||
"@automaker/utils": "1.0.0",
|
||||
"@github/copilot-sdk": "^0.1.16",
|
||||
"@modelcontextprotocol/sdk": "1.25.2",
|
||||
"@openai/codex-sdk": "^0.77.0",
|
||||
"cookie-parser": "1.4.7",
|
||||
@@ -40,7 +41,8 @@
|
||||
"express": "5.2.1",
|
||||
"morgan": "1.10.1",
|
||||
"node-pty": "1.1.0-beta41",
|
||||
"ws": "8.18.3"
|
||||
"ws": "8.18.3",
|
||||
"yaml": "2.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cookie": "0.6.0",
|
||||
|
||||
@@ -16,10 +16,20 @@ import { createServer } from 'http';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
import { createEventEmitter, type EventEmitter } from './lib/events.js';
|
||||
import { initAllowedPaths } from '@automaker/platform';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { initAllowedPaths, getClaudeAuthIndicators } from '@automaker/platform';
|
||||
import { createLogger, setLogLevel, LogLevel } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('Server');
|
||||
|
||||
/**
|
||||
* Map server log level string to LogLevel enum
|
||||
*/
|
||||
const LOG_LEVEL_MAP: Record<string, LogLevel> = {
|
||||
error: LogLevel.ERROR,
|
||||
warn: LogLevel.WARN,
|
||||
info: LogLevel.INFO,
|
||||
debug: LogLevel.DEBUG,
|
||||
};
|
||||
import { authMiddleware, validateWsConnectionToken, checkRawAuthentication } from './lib/auth.js';
|
||||
import { requireJsonContentType } from './middleware/require-json-content-type.js';
|
||||
import { createAuthRoutes } from './routes/auth/index.js';
|
||||
@@ -33,7 +43,6 @@ import { createEnhancePromptRoutes } from './routes/enhance-prompt/index.js';
|
||||
import { createWorktreeRoutes } from './routes/worktree/index.js';
|
||||
import { createGitRoutes } from './routes/git/index.js';
|
||||
import { createSetupRoutes } from './routes/setup/index.js';
|
||||
import { createSuggestionsRoutes } from './routes/suggestions/index.js';
|
||||
import { createModelsRoutes } from './routes/models/index.js';
|
||||
import { createRunningAgentsRoutes } from './routes/running-agents/index.js';
|
||||
import { createWorkspaceRoutes } from './routes/workspace/index.js';
|
||||
@@ -68,34 +77,104 @@ import { pipelineService } from './services/pipeline-service.js';
|
||||
import { createIdeationRoutes } from './routes/ideation/index.js';
|
||||
import { IdeationService } from './services/ideation-service.js';
|
||||
import { getDevServerService } from './services/dev-server-service.js';
|
||||
import { eventHookService } from './services/event-hook-service.js';
|
||||
import { createNotificationsRoutes } from './routes/notifications/index.js';
|
||||
import { getNotificationService } from './services/notification-service.js';
|
||||
import { createEventHistoryRoutes } from './routes/event-history/index.js';
|
||||
import { getEventHistoryService } from './services/event-history-service.js';
|
||||
import { getTestRunnerService } from './services/test-runner-service.js';
|
||||
import { createProjectsRoutes } from './routes/projects/index.js';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
const PORT = parseInt(process.env.PORT || '3008', 10);
|
||||
const HOST = process.env.HOST || '0.0.0.0';
|
||||
const HOSTNAME = process.env.HOSTNAME || 'localhost';
|
||||
const DATA_DIR = process.env.DATA_DIR || './data';
|
||||
const ENABLE_REQUEST_LOGGING = process.env.ENABLE_REQUEST_LOGGING !== 'false'; // Default to true
|
||||
logger.info('[SERVER_STARTUP] process.env.DATA_DIR:', process.env.DATA_DIR);
|
||||
logger.info('[SERVER_STARTUP] Resolved DATA_DIR:', DATA_DIR);
|
||||
logger.info('[SERVER_STARTUP] process.cwd():', process.cwd());
|
||||
const ENABLE_REQUEST_LOGGING_DEFAULT = process.env.ENABLE_REQUEST_LOGGING !== 'false'; // Default to true
|
||||
|
||||
// Check for required environment variables
|
||||
const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
|
||||
// Runtime-configurable request logging flag (can be changed via settings)
|
||||
let requestLoggingEnabled = ENABLE_REQUEST_LOGGING_DEFAULT;
|
||||
|
||||
if (!hasAnthropicKey) {
|
||||
logger.warn(`
|
||||
╔═══════════════════════════════════════════════════════════════════════╗
|
||||
║ ⚠️ WARNING: No Claude authentication configured ║
|
||||
║ ║
|
||||
║ The Claude Agent SDK requires authentication to function. ║
|
||||
║ ║
|
||||
║ Set your Anthropic API key: ║
|
||||
║ export ANTHROPIC_API_KEY="sk-ant-..." ║
|
||||
║ ║
|
||||
║ Or use the setup wizard in Settings to configure authentication. ║
|
||||
╚═══════════════════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
} else {
|
||||
logger.info('✓ ANTHROPIC_API_KEY detected (API key auth)');
|
||||
/**
|
||||
* Enable or disable HTTP request logging at runtime
|
||||
*/
|
||||
export function setRequestLoggingEnabled(enabled: boolean): void {
|
||||
requestLoggingEnabled = enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current request logging state
|
||||
*/
|
||||
export function isRequestLoggingEnabled(): boolean {
|
||||
return requestLoggingEnabled;
|
||||
}
|
||||
|
||||
// Width for log box content (excluding borders)
|
||||
const BOX_CONTENT_WIDTH = 67;
|
||||
|
||||
// Check for Claude authentication (async - runs in background)
|
||||
// The Claude Agent SDK can use either ANTHROPIC_API_KEY or Claude Code CLI authentication
|
||||
(async () => {
|
||||
const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
|
||||
|
||||
if (hasAnthropicKey) {
|
||||
logger.info('✓ ANTHROPIC_API_KEY detected');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for Claude Code CLI authentication
|
||||
try {
|
||||
const indicators = await getClaudeAuthIndicators();
|
||||
const hasCliAuth =
|
||||
indicators.hasStatsCacheWithActivity ||
|
||||
(indicators.hasSettingsFile && indicators.hasProjectsSessions) ||
|
||||
(indicators.hasCredentialsFile &&
|
||||
(indicators.credentials?.hasOAuthToken || indicators.credentials?.hasApiKey));
|
||||
|
||||
if (hasCliAuth) {
|
||||
logger.info('✓ Claude Code CLI authentication detected');
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore errors checking CLI auth - will fall through to warning
|
||||
logger.warn('Error checking for Claude Code CLI authentication:', error);
|
||||
}
|
||||
|
||||
// No authentication found - show warning
|
||||
const wHeader = '⚠️ WARNING: No Claude authentication configured'.padEnd(BOX_CONTENT_WIDTH);
|
||||
const w1 = 'The Claude Agent SDK requires authentication to function.'.padEnd(BOX_CONTENT_WIDTH);
|
||||
const w2 = 'Options:'.padEnd(BOX_CONTENT_WIDTH);
|
||||
const w3 = '1. Install Claude Code CLI and authenticate with subscription'.padEnd(
|
||||
BOX_CONTENT_WIDTH
|
||||
);
|
||||
const w4 = '2. Set your Anthropic API key:'.padEnd(BOX_CONTENT_WIDTH);
|
||||
const w5 = ' export ANTHROPIC_API_KEY="sk-ant-..."'.padEnd(BOX_CONTENT_WIDTH);
|
||||
const w6 = '3. Use the setup wizard in Settings to configure authentication.'.padEnd(
|
||||
BOX_CONTENT_WIDTH
|
||||
);
|
||||
|
||||
logger.warn(`
|
||||
╔═════════════════════════════════════════════════════════════════════╗
|
||||
║ ${wHeader}║
|
||||
╠═════════════════════════════════════════════════════════════════════╣
|
||||
║ ║
|
||||
║ ${w1}║
|
||||
║ ║
|
||||
║ ${w2}║
|
||||
║ ${w3}║
|
||||
║ ${w4}║
|
||||
║ ${w5}║
|
||||
║ ${w6}║
|
||||
║ ║
|
||||
╚═════════════════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
})();
|
||||
|
||||
// Initialize security
|
||||
initAllowedPaths();
|
||||
|
||||
@@ -103,22 +182,21 @@ initAllowedPaths();
|
||||
const app = express();
|
||||
|
||||
// Middleware
|
||||
// Custom colored logger showing only endpoint and status code (configurable via ENABLE_REQUEST_LOGGING env var)
|
||||
if (ENABLE_REQUEST_LOGGING) {
|
||||
morgan.token('status-colored', (_req, res) => {
|
||||
const status = res.statusCode;
|
||||
if (status >= 500) return `\x1b[31m${status}\x1b[0m`; // Red for server errors
|
||||
if (status >= 400) return `\x1b[33m${status}\x1b[0m`; // Yellow for client errors
|
||||
if (status >= 300) return `\x1b[36m${status}\x1b[0m`; // Cyan for redirects
|
||||
return `\x1b[32m${status}\x1b[0m`; // Green for success
|
||||
});
|
||||
// Custom colored logger showing only endpoint and status code (dynamically configurable)
|
||||
morgan.token('status-colored', (_req, res) => {
|
||||
const status = res.statusCode;
|
||||
if (status >= 500) return `\x1b[31m${status}\x1b[0m`; // Red for server errors
|
||||
if (status >= 400) return `\x1b[33m${status}\x1b[0m`; // Yellow for client errors
|
||||
if (status >= 300) return `\x1b[36m${status}\x1b[0m`; // Cyan for redirects
|
||||
return `\x1b[32m${status}\x1b[0m`; // Green for success
|
||||
});
|
||||
|
||||
app.use(
|
||||
morgan(':method :url :status-colored', {
|
||||
skip: (req) => req.url === '/api/health', // Skip health check logs
|
||||
})
|
||||
);
|
||||
}
|
||||
app.use(
|
||||
morgan(':method :url :status-colored', {
|
||||
// Skip when request logging is disabled or for health check endpoints
|
||||
skip: (req) => !requestLoggingEnabled || req.url === '/api/health',
|
||||
})
|
||||
);
|
||||
// CORS configuration
|
||||
// When using credentials (cookies), origin cannot be '*'
|
||||
// We dynamically allow the requesting origin for local development
|
||||
@@ -142,14 +220,25 @@ app.use(
|
||||
return;
|
||||
}
|
||||
|
||||
// For local development, allow localhost origins
|
||||
if (
|
||||
origin.startsWith('http://localhost:') ||
|
||||
origin.startsWith('http://127.0.0.1:') ||
|
||||
origin.startsWith('http://[::1]:')
|
||||
) {
|
||||
callback(null, origin);
|
||||
return;
|
||||
// For local development, allow all localhost/loopback origins (any port)
|
||||
try {
|
||||
const url = new URL(origin);
|
||||
const hostname = url.hostname;
|
||||
|
||||
if (
|
||||
hostname === 'localhost' ||
|
||||
hostname === '127.0.0.1' ||
|
||||
hostname === '::1' ||
|
||||
hostname === '0.0.0.0' ||
|
||||
hostname.startsWith('192.168.') ||
|
||||
hostname.startsWith('10.') ||
|
||||
hostname.startsWith('172.')
|
||||
) {
|
||||
callback(null, origin);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore URL parsing errors
|
||||
}
|
||||
|
||||
// Reject other origins by default for security
|
||||
@@ -181,8 +270,54 @@ const ideationService = new IdeationService(events, settingsService, featureLoad
|
||||
const devServerService = getDevServerService();
|
||||
devServerService.setEventEmitter(events);
|
||||
|
||||
// Initialize Notification Service with event emitter for real-time updates
|
||||
const notificationService = getNotificationService();
|
||||
notificationService.setEventEmitter(events);
|
||||
|
||||
// Initialize Event History Service
|
||||
const eventHistoryService = getEventHistoryService();
|
||||
|
||||
// Initialize Test Runner Service with event emitter for real-time test output streaming
|
||||
const testRunnerService = getTestRunnerService();
|
||||
testRunnerService.setEventEmitter(events);
|
||||
|
||||
// Initialize Event Hook Service for custom event triggers (with history storage)
|
||||
eventHookService.initialize(events, settingsService, eventHistoryService, featureLoader);
|
||||
|
||||
// Initialize services
|
||||
(async () => {
|
||||
// Migrate settings from legacy Electron userData location if needed
|
||||
// This handles users upgrading from versions that stored settings in ~/.config/Automaker (Linux),
|
||||
// ~/Library/Application Support/Automaker (macOS), or %APPDATA%\Automaker (Windows)
|
||||
// to the new shared ./data directory
|
||||
try {
|
||||
const migrationResult = await settingsService.migrateFromLegacyElectronPath();
|
||||
if (migrationResult.migrated) {
|
||||
logger.info(`Settings migrated from legacy location: ${migrationResult.legacyPath}`);
|
||||
logger.info(`Migrated files: ${migrationResult.migratedFiles.join(', ')}`);
|
||||
}
|
||||
if (migrationResult.errors.length > 0) {
|
||||
logger.warn('Migration errors:', migrationResult.errors);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Failed to check for legacy settings migration:', err);
|
||||
}
|
||||
|
||||
// Apply logging settings from saved settings
|
||||
try {
|
||||
const settings = await settingsService.getGlobalSettings();
|
||||
if (settings.serverLogLevel && LOG_LEVEL_MAP[settings.serverLogLevel] !== undefined) {
|
||||
setLogLevel(LOG_LEVEL_MAP[settings.serverLogLevel]);
|
||||
logger.info(`Server log level set to: ${settings.serverLogLevel}`);
|
||||
}
|
||||
// Apply request logging setting (default true if not set)
|
||||
const enableRequestLog = settings.enableRequestLogging ?? true;
|
||||
setRequestLoggingEnabled(enableRequestLog);
|
||||
logger.info(`HTTP request logging: ${enableRequestLog ? 'enabled' : 'disabled'}`);
|
||||
} catch (err) {
|
||||
logger.warn('Failed to load logging settings, using defaults');
|
||||
}
|
||||
|
||||
await agentService.initialize();
|
||||
logger.info('Agent service initialized');
|
||||
|
||||
@@ -219,12 +354,14 @@ app.get('/api/health/detailed', createDetailedHandler());
|
||||
app.use('/api/fs', createFsRoutes(events));
|
||||
app.use('/api/agent', createAgentRoutes(agentService, events));
|
||||
app.use('/api/sessions', createSessionsRoutes(agentService));
|
||||
app.use('/api/features', createFeaturesRoutes(featureLoader));
|
||||
app.use(
|
||||
'/api/features',
|
||||
createFeaturesRoutes(featureLoader, settingsService, events, autoModeService)
|
||||
);
|
||||
app.use('/api/auto-mode', createAutoModeRoutes(autoModeService));
|
||||
app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService));
|
||||
app.use('/api/worktree', createWorktreeRoutes(events, settingsService));
|
||||
app.use('/api/git', createGitRoutes());
|
||||
app.use('/api/suggestions', createSuggestionsRoutes(events, settingsService));
|
||||
app.use('/api/models', createModelsRoutes());
|
||||
app.use('/api/spec-regeneration', createSpecRegenerationRoutes(events, settingsService));
|
||||
app.use('/api/running-agents', createRunningAgentsRoutes(autoModeService));
|
||||
@@ -240,6 +377,12 @@ 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));
|
||||
app.use('/api/notifications', createNotificationsRoutes(notificationService));
|
||||
app.use('/api/event-history', createEventHistoryRoutes(eventHistoryService, settingsService));
|
||||
app.use(
|
||||
'/api/projects',
|
||||
createProjectsRoutes(featureLoader, autoModeService, settingsService, notificationService)
|
||||
);
|
||||
|
||||
// Create HTTP server
|
||||
const server = createServer(app);
|
||||
@@ -551,46 +694,81 @@ terminalWss.on('connection', (ws: WebSocket, req: import('http').IncomingMessage
|
||||
});
|
||||
|
||||
// Start server with error handling for port conflicts
|
||||
const startServer = (port: number) => {
|
||||
server.listen(port, () => {
|
||||
const startServer = (port: number, host: string) => {
|
||||
server.listen(port, host, () => {
|
||||
const terminalStatus = isTerminalEnabled()
|
||||
? isTerminalPasswordRequired()
|
||||
? 'enabled (password protected)'
|
||||
: 'enabled'
|
||||
: 'disabled';
|
||||
const portStr = port.toString().padEnd(4);
|
||||
|
||||
// Build URLs for display
|
||||
const listenAddr = `${host}:${port}`;
|
||||
const httpUrl = `http://${HOSTNAME}:${port}`;
|
||||
const wsEventsUrl = `ws://${HOSTNAME}:${port}/api/events`;
|
||||
const wsTerminalUrl = `ws://${HOSTNAME}:${port}/api/terminal/ws`;
|
||||
const healthUrl = `http://${HOSTNAME}:${port}/api/health`;
|
||||
|
||||
const sHeader = '🚀 Automaker Backend Server'.padEnd(BOX_CONTENT_WIDTH);
|
||||
const s1 = `Listening: ${listenAddr}`.padEnd(BOX_CONTENT_WIDTH);
|
||||
const s2 = `HTTP API: ${httpUrl}`.padEnd(BOX_CONTENT_WIDTH);
|
||||
const s3 = `WebSocket: ${wsEventsUrl}`.padEnd(BOX_CONTENT_WIDTH);
|
||||
const s4 = `Terminal WS: ${wsTerminalUrl}`.padEnd(BOX_CONTENT_WIDTH);
|
||||
const s5 = `Health: ${healthUrl}`.padEnd(BOX_CONTENT_WIDTH);
|
||||
const s6 = `Terminal: ${terminalStatus}`.padEnd(BOX_CONTENT_WIDTH);
|
||||
|
||||
logger.info(`
|
||||
╔═══════════════════════════════════════════════════════╗
|
||||
║ Automaker Backend Server ║
|
||||
╠═══════════════════════════════════════════════════════╣
|
||||
║ HTTP API: http://localhost:${portStr} ║
|
||||
║ WebSocket: ws://localhost:${portStr}/api/events ║
|
||||
║ Terminal: ws://localhost:${portStr}/api/terminal/ws ║
|
||||
║ Health: http://localhost:${portStr}/api/health ║
|
||||
║ Terminal: ${terminalStatus.padEnd(37)}║
|
||||
╚═══════════════════════════════════════════════════════╝
|
||||
╔═════════════════════════════════════════════════════════════════════╗
|
||||
║ ${sHeader}║
|
||||
╠═════════════════════════════════════════════════════════════════════╣
|
||||
║ ║
|
||||
║ ${s1}║
|
||||
║ ${s2}║
|
||||
║ ${s3}║
|
||||
║ ${s4}║
|
||||
║ ${s5}║
|
||||
║ ${s6}║
|
||||
║ ║
|
||||
╚═════════════════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
});
|
||||
|
||||
server.on('error', (error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'EADDRINUSE') {
|
||||
const portStr = port.toString();
|
||||
const nextPortStr = (port + 1).toString();
|
||||
const killCmd = `lsof -ti:${portStr} | xargs kill -9`;
|
||||
const altCmd = `PORT=${nextPortStr} npm run dev:server`;
|
||||
|
||||
const eHeader = `❌ ERROR: Port ${portStr} is already in use`.padEnd(BOX_CONTENT_WIDTH);
|
||||
const e1 = 'Another process is using this port.'.padEnd(BOX_CONTENT_WIDTH);
|
||||
const e2 = 'To fix this, try one of:'.padEnd(BOX_CONTENT_WIDTH);
|
||||
const e3 = '1. Kill the process using the port:'.padEnd(BOX_CONTENT_WIDTH);
|
||||
const e4 = ` ${killCmd}`.padEnd(BOX_CONTENT_WIDTH);
|
||||
const e5 = '2. Use a different port:'.padEnd(BOX_CONTENT_WIDTH);
|
||||
const e6 = ` ${altCmd}`.padEnd(BOX_CONTENT_WIDTH);
|
||||
const e7 = '3. Use the init.sh script which handles this:'.padEnd(BOX_CONTENT_WIDTH);
|
||||
const e8 = ' ./init.sh'.padEnd(BOX_CONTENT_WIDTH);
|
||||
|
||||
logger.error(`
|
||||
╔═══════════════════════════════════════════════════════╗
|
||||
║ ❌ ERROR: Port ${port} is already in use ║
|
||||
╠═══════════════════════════════════════════════════════╣
|
||||
║ Another process is using this port. ║
|
||||
║ ║
|
||||
║ To fix this, try one of: ║
|
||||
║ ║
|
||||
║ 1. Kill the process using the port: ║
|
||||
║ lsof -ti:${port} | xargs kill -9 ║
|
||||
║ ║
|
||||
║ 2. Use a different port: ║
|
||||
║ PORT=${port + 1} npm run dev:server ║
|
||||
║ ║
|
||||
║ 3. Use the init.sh script which handles this: ║
|
||||
║ ./init.sh ║
|
||||
╚═══════════════════════════════════════════════════════╝
|
||||
╔═════════════════════════════════════════════════════════════════════╗
|
||||
║ ${eHeader}║
|
||||
╠═════════════════════════════════════════════════════════════════════╣
|
||||
║ ║
|
||||
║ ${e1}║
|
||||
║ ║
|
||||
║ ${e2}║
|
||||
║ ║
|
||||
║ ${e3}║
|
||||
║ ${e4}║
|
||||
║ ║
|
||||
║ ${e5}║
|
||||
║ ${e6}║
|
||||
║ ║
|
||||
║ ${e7}║
|
||||
║ ${e8}║
|
||||
║ ║
|
||||
╚═════════════════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
process.exit(1);
|
||||
} else {
|
||||
@@ -600,7 +778,7 @@ const startServer = (port: number) => {
|
||||
});
|
||||
};
|
||||
|
||||
startServer(PORT);
|
||||
startServer(PORT, HOST);
|
||||
|
||||
// Global error handlers to prevent crashes from uncaught errors
|
||||
process.on('unhandledRejection', (reason: unknown, _promise: Promise<unknown>) => {
|
||||
@@ -622,21 +800,36 @@ process.on('uncaughtException', (error: Error) => {
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', () => {
|
||||
logger.info('SIGTERM received, shutting down...');
|
||||
// Graceful shutdown timeout (30 seconds)
|
||||
const SHUTDOWN_TIMEOUT_MS = 30000;
|
||||
|
||||
// Graceful shutdown helper
|
||||
const gracefulShutdown = async (signal: string) => {
|
||||
logger.info(`${signal} received, shutting down...`);
|
||||
|
||||
// Set up a force-exit timeout to prevent hanging
|
||||
const forceExitTimeout = setTimeout(() => {
|
||||
logger.error(`Shutdown timed out after ${SHUTDOWN_TIMEOUT_MS}ms, forcing exit`);
|
||||
process.exit(1);
|
||||
}, SHUTDOWN_TIMEOUT_MS);
|
||||
|
||||
// Mark all running features as interrupted before shutdown
|
||||
// This ensures they can be resumed when the server restarts
|
||||
// Note: markAllRunningFeaturesInterrupted handles errors internally and never rejects
|
||||
await autoModeService.markAllRunningFeaturesInterrupted(`${signal} signal received`);
|
||||
|
||||
terminalService.cleanup();
|
||||
server.close(() => {
|
||||
clearTimeout(forceExitTimeout);
|
||||
logger.info('Server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
};
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
gracefulShutdown('SIGTERM');
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
logger.info('SIGINT received, shutting down...');
|
||||
terminalService.cleanup();
|
||||
server.close(() => {
|
||||
logger.info('Server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
gracefulShutdown('SIGINT');
|
||||
});
|
||||
|
||||
@@ -11,8 +11,12 @@ export { specOutputSchema } from '@automaker/types';
|
||||
|
||||
/**
|
||||
* Escape special XML characters
|
||||
* Handles undefined/null values by converting them to empty strings
|
||||
*/
|
||||
function escapeXml(str: string): string {
|
||||
export function escapeXml(str: string | undefined | null): string {
|
||||
if (str == null) {
|
||||
return '';
|
||||
}
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
|
||||
@@ -23,6 +23,13 @@ const SESSION_COOKIE_NAME = 'automaker_session';
|
||||
const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
const WS_TOKEN_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes for WebSocket connection tokens
|
||||
|
||||
/**
|
||||
* Check if an environment variable is set to 'true'
|
||||
*/
|
||||
function isEnvTrue(envVar: string | undefined): boolean {
|
||||
return envVar === 'true';
|
||||
}
|
||||
|
||||
// Session store - persisted to file for survival across server restarts
|
||||
const validSessions = new Map<string, { createdAt: number; expiresAt: number }>();
|
||||
|
||||
@@ -130,19 +137,47 @@ function ensureApiKey(): string {
|
||||
// API key - always generated/loaded on startup for CSRF protection
|
||||
const API_KEY = ensureApiKey();
|
||||
|
||||
// Width for log box content (excluding borders)
|
||||
const BOX_CONTENT_WIDTH = 67;
|
||||
|
||||
// Print API key to console for web mode users (unless suppressed for production logging)
|
||||
if (process.env.AUTOMAKER_HIDE_API_KEY !== 'true') {
|
||||
if (!isEnvTrue(process.env.AUTOMAKER_HIDE_API_KEY)) {
|
||||
const autoLoginEnabled = isEnvTrue(process.env.AUTOMAKER_AUTO_LOGIN);
|
||||
const autoLoginStatus = autoLoginEnabled ? 'enabled (auto-login active)' : 'disabled';
|
||||
|
||||
// Build box lines with exact padding
|
||||
const header = '🔐 API Key for Web Mode Authentication'.padEnd(BOX_CONTENT_WIDTH);
|
||||
const line1 = "When accessing via browser, you'll be prompted to enter this key:".padEnd(
|
||||
BOX_CONTENT_WIDTH
|
||||
);
|
||||
const line2 = API_KEY.padEnd(BOX_CONTENT_WIDTH);
|
||||
const line3 = 'In Electron mode, authentication is handled automatically.'.padEnd(
|
||||
BOX_CONTENT_WIDTH
|
||||
);
|
||||
const line4 = `Auto-login (AUTOMAKER_AUTO_LOGIN): ${autoLoginStatus}`.padEnd(BOX_CONTENT_WIDTH);
|
||||
const tipHeader = '💡 Tips'.padEnd(BOX_CONTENT_WIDTH);
|
||||
const line5 = 'Set AUTOMAKER_API_KEY env var to use a fixed key'.padEnd(BOX_CONTENT_WIDTH);
|
||||
const line6 = 'Set AUTOMAKER_AUTO_LOGIN=true to skip the login prompt'.padEnd(BOX_CONTENT_WIDTH);
|
||||
|
||||
logger.info(`
|
||||
╔═══════════════════════════════════════════════════════════════════════╗
|
||||
║ 🔐 API Key for Web Mode Authentication ║
|
||||
╠═══════════════════════════════════════════════════════════════════════╣
|
||||
║ ║
|
||||
║ When accessing via browser, you'll be prompted to enter this key: ║
|
||||
║ ║
|
||||
║ ${API_KEY}
|
||||
║ ║
|
||||
║ In Electron mode, authentication is handled automatically. ║
|
||||
╚═══════════════════════════════════════════════════════════════════════╝
|
||||
╔═════════════════════════════════════════════════════════════════════╗
|
||||
║ ${header}║
|
||||
╠═════════════════════════════════════════════════════════════════════╣
|
||||
║ ║
|
||||
║ ${line1}║
|
||||
║ ║
|
||||
║ ${line2}║
|
||||
║ ║
|
||||
║ ${line3}║
|
||||
║ ║
|
||||
║ ${line4}║
|
||||
║ ║
|
||||
╠═════════════════════════════════════════════════════════════════════╣
|
||||
║ ${tipHeader}║
|
||||
╠═════════════════════════════════════════════════════════════════════╣
|
||||
║ ${line5}║
|
||||
║ ${line6}║
|
||||
╚═════════════════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
} else {
|
||||
logger.info('API key banner hidden (AUTOMAKER_HIDE_API_KEY=true)');
|
||||
@@ -318,6 +353,15 @@ function checkAuthentication(
|
||||
return { authenticated: false, errorType: 'invalid_api_key' };
|
||||
}
|
||||
|
||||
// Check for session token in query parameter (web mode - needed for image loads)
|
||||
const queryToken = query.token;
|
||||
if (queryToken) {
|
||||
if (validateSession(queryToken)) {
|
||||
return { authenticated: true };
|
||||
}
|
||||
return { authenticated: false, errorType: 'invalid_session' };
|
||||
}
|
||||
|
||||
// Check for session cookie (web mode)
|
||||
const sessionToken = cookies[SESSION_COOKIE_NAME];
|
||||
if (sessionToken && validateSession(sessionToken)) {
|
||||
@@ -333,10 +377,17 @@ function checkAuthentication(
|
||||
* Accepts either:
|
||||
* 1. X-API-Key header (for Electron mode)
|
||||
* 2. X-Session-Token header (for web mode with explicit token)
|
||||
* 3. apiKey query parameter (fallback for cases where headers can't be set)
|
||||
* 4. Session cookie (for web mode)
|
||||
* 3. apiKey query parameter (fallback for Electron, cases where headers can't be set)
|
||||
* 4. token query parameter (fallback for web mode, needed for image loads via CSS/img tags)
|
||||
* 5. Session cookie (for web mode)
|
||||
*/
|
||||
export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
|
||||
// Allow disabling auth for local/trusted networks
|
||||
if (isEnvTrue(process.env.AUTOMAKER_DISABLE_AUTH)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const result = checkAuthentication(
|
||||
req.headers as Record<string, string | string[] | undefined>,
|
||||
req.query as Record<string, string | undefined>,
|
||||
@@ -382,9 +433,10 @@ export function isAuthEnabled(): boolean {
|
||||
* Get authentication status for health endpoint
|
||||
*/
|
||||
export function getAuthStatus(): { enabled: boolean; method: string } {
|
||||
const disabled = isEnvTrue(process.env.AUTOMAKER_DISABLE_AUTH);
|
||||
return {
|
||||
enabled: true,
|
||||
method: 'api_key_or_session',
|
||||
enabled: !disabled,
|
||||
method: disabled ? 'disabled' : 'api_key_or_session',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -392,6 +444,7 @@ export function getAuthStatus(): { enabled: boolean; method: string } {
|
||||
* Check if a request is authenticated (for status endpoint)
|
||||
*/
|
||||
export function isRequestAuthenticated(req: Request): boolean {
|
||||
if (isEnvTrue(process.env.AUTOMAKER_DISABLE_AUTH)) return true;
|
||||
const result = checkAuthentication(
|
||||
req.headers as Record<string, string | string[] | undefined>,
|
||||
req.query as Record<string, string | undefined>,
|
||||
@@ -409,5 +462,6 @@ export function checkRawAuthentication(
|
||||
query: Record<string, string | undefined>,
|
||||
cookies: Record<string, string | undefined>
|
||||
): boolean {
|
||||
if (isEnvTrue(process.env.AUTOMAKER_DISABLE_AUTH)) return true;
|
||||
return checkAuthentication(headers, query, cookies).authenticated;
|
||||
}
|
||||
|
||||
@@ -5,12 +5,30 @@
|
||||
import type { SettingsService } from '../services/settings-service.js';
|
||||
import type { ContextFilesResult, ContextFileInfo } from '@automaker/utils';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { MCPServerConfig, McpServerConfig, PromptCustomization } from '@automaker/types';
|
||||
import type {
|
||||
MCPServerConfig,
|
||||
McpServerConfig,
|
||||
PromptCustomization,
|
||||
ClaudeApiProfile,
|
||||
ClaudeCompatibleProvider,
|
||||
PhaseModelKey,
|
||||
PhaseModelEntry,
|
||||
Credentials,
|
||||
} from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
|
||||
import {
|
||||
mergeAutoModePrompts,
|
||||
mergeAgentPrompts,
|
||||
mergeBacklogPlanPrompts,
|
||||
mergeEnhancementPrompts,
|
||||
mergeCommitMessagePrompts,
|
||||
mergeTitleGenerationPrompts,
|
||||
mergeIssueValidationPrompts,
|
||||
mergeIdeationPrompts,
|
||||
mergeAppSpecPrompts,
|
||||
mergeContextDescriptionPrompts,
|
||||
mergeSuggestionsPrompts,
|
||||
mergeTaskExecutionPrompts,
|
||||
} from '@automaker/prompts';
|
||||
|
||||
const logger = createLogger('SettingsHelper');
|
||||
@@ -218,6 +236,14 @@ export async function getPromptCustomization(
|
||||
agent: ReturnType<typeof mergeAgentPrompts>;
|
||||
backlogPlan: ReturnType<typeof mergeBacklogPlanPrompts>;
|
||||
enhancement: ReturnType<typeof mergeEnhancementPrompts>;
|
||||
commitMessage: ReturnType<typeof mergeCommitMessagePrompts>;
|
||||
titleGeneration: ReturnType<typeof mergeTitleGenerationPrompts>;
|
||||
issueValidation: ReturnType<typeof mergeIssueValidationPrompts>;
|
||||
ideation: ReturnType<typeof mergeIdeationPrompts>;
|
||||
appSpec: ReturnType<typeof mergeAppSpecPrompts>;
|
||||
contextDescription: ReturnType<typeof mergeContextDescriptionPrompts>;
|
||||
suggestions: ReturnType<typeof mergeSuggestionsPrompts>;
|
||||
taskExecution: ReturnType<typeof mergeTaskExecutionPrompts>;
|
||||
}> {
|
||||
let customization: PromptCustomization = {};
|
||||
|
||||
@@ -239,6 +265,14 @@ export async function getPromptCustomization(
|
||||
agent: mergeAgentPrompts(customization.agent),
|
||||
backlogPlan: mergeBacklogPlanPrompts(customization.backlogPlan),
|
||||
enhancement: mergeEnhancementPrompts(customization.enhancement),
|
||||
commitMessage: mergeCommitMessagePrompts(customization.commitMessage),
|
||||
titleGeneration: mergeTitleGenerationPrompts(customization.titleGeneration),
|
||||
issueValidation: mergeIssueValidationPrompts(customization.issueValidation),
|
||||
ideation: mergeIdeationPrompts(customization.ideation),
|
||||
appSpec: mergeAppSpecPrompts(customization.appSpec),
|
||||
contextDescription: mergeContextDescriptionPrompts(customization.contextDescription),
|
||||
suggestions: mergeSuggestionsPrompts(customization.suggestions),
|
||||
taskExecution: mergeTaskExecutionPrompts(customization.taskExecution),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -321,3 +355,376 @@ export async function getCustomSubagents(
|
||||
|
||||
return Object.keys(merged).length > 0 ? merged : undefined;
|
||||
}
|
||||
|
||||
/** Result from getActiveClaudeApiProfile */
|
||||
export interface ActiveClaudeApiProfileResult {
|
||||
/** The active profile, or undefined if using direct Anthropic API */
|
||||
profile: ClaudeApiProfile | undefined;
|
||||
/** Credentials for resolving 'credentials' apiKeySource */
|
||||
credentials: import('@automaker/types').Credentials | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active Claude API profile and credentials from settings.
|
||||
* Checks project settings first for per-project overrides, then falls back to global settings.
|
||||
* Returns both the profile and credentials for resolving 'credentials' apiKeySource.
|
||||
*
|
||||
* @deprecated Use getProviderById and getPhaseModelWithOverrides instead for the new provider system.
|
||||
* This function is kept for backward compatibility during migration.
|
||||
*
|
||||
* @param settingsService - Optional settings service instance
|
||||
* @param logPrefix - Prefix for log messages (e.g., '[AgentService]')
|
||||
* @param projectPath - Optional project path for per-project override
|
||||
* @returns Promise resolving to object with profile and credentials
|
||||
*/
|
||||
export async function getActiveClaudeApiProfile(
|
||||
settingsService?: SettingsService | null,
|
||||
logPrefix = '[SettingsHelper]',
|
||||
projectPath?: string
|
||||
): Promise<ActiveClaudeApiProfileResult> {
|
||||
if (!settingsService) {
|
||||
return { profile: undefined, credentials: undefined };
|
||||
}
|
||||
|
||||
try {
|
||||
const globalSettings = await settingsService.getGlobalSettings();
|
||||
const credentials = await settingsService.getCredentials();
|
||||
const profiles = globalSettings.claudeApiProfiles || [];
|
||||
|
||||
// Check for project-level override first
|
||||
let activeProfileId: string | null | undefined;
|
||||
let isProjectOverride = false;
|
||||
|
||||
if (projectPath) {
|
||||
const projectSettings = await settingsService.getProjectSettings(projectPath);
|
||||
// undefined = use global, null = explicit no profile, string = specific profile
|
||||
if (projectSettings.activeClaudeApiProfileId !== undefined) {
|
||||
activeProfileId = projectSettings.activeClaudeApiProfileId;
|
||||
isProjectOverride = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to global if project doesn't specify
|
||||
if (activeProfileId === undefined && !isProjectOverride) {
|
||||
activeProfileId = globalSettings.activeClaudeApiProfileId;
|
||||
}
|
||||
|
||||
// No active profile selected - use direct Anthropic API
|
||||
if (!activeProfileId) {
|
||||
if (isProjectOverride && activeProfileId === null) {
|
||||
logger.info(`${logPrefix} Project explicitly using Direct Anthropic API`);
|
||||
}
|
||||
return { profile: undefined, credentials };
|
||||
}
|
||||
|
||||
// Find the active profile by ID
|
||||
const activeProfile = profiles.find((p) => p.id === activeProfileId);
|
||||
|
||||
if (activeProfile) {
|
||||
const overrideSuffix = isProjectOverride ? ' (project override)' : '';
|
||||
logger.info(`${logPrefix} Using Claude API profile: ${activeProfile.name}${overrideSuffix}`);
|
||||
return { profile: activeProfile, credentials };
|
||||
} else {
|
||||
logger.warn(
|
||||
`${logPrefix} Active profile ID "${activeProfileId}" not found, falling back to direct Anthropic API`
|
||||
);
|
||||
return { profile: undefined, credentials };
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Failed to load Claude API profile:`, error);
|
||||
return { profile: undefined, credentials: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// New Provider System Helpers
|
||||
// ============================================================================
|
||||
|
||||
/** Result from getProviderById */
|
||||
export interface ProviderByIdResult {
|
||||
/** The provider, or undefined if not found */
|
||||
provider: ClaudeCompatibleProvider | undefined;
|
||||
/** Credentials for resolving 'credentials' apiKeySource */
|
||||
credentials: Credentials | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a ClaudeCompatibleProvider by its ID.
|
||||
* Returns the provider configuration and credentials for API key resolution.
|
||||
*
|
||||
* @param providerId - The provider ID to look up
|
||||
* @param settingsService - Settings service instance
|
||||
* @param logPrefix - Prefix for log messages
|
||||
* @returns Promise resolving to object with provider and credentials
|
||||
*/
|
||||
export async function getProviderById(
|
||||
providerId: string,
|
||||
settingsService: SettingsService,
|
||||
logPrefix = '[SettingsHelper]'
|
||||
): Promise<ProviderByIdResult> {
|
||||
try {
|
||||
const globalSettings = await settingsService.getGlobalSettings();
|
||||
const credentials = await settingsService.getCredentials();
|
||||
const providers = globalSettings.claudeCompatibleProviders || [];
|
||||
|
||||
const provider = providers.find((p) => p.id === providerId);
|
||||
|
||||
if (provider) {
|
||||
if (provider.enabled === false) {
|
||||
logger.warn(`${logPrefix} Provider "${provider.name}" (${providerId}) is disabled`);
|
||||
} else {
|
||||
logger.debug(`${logPrefix} Found provider: ${provider.name}`);
|
||||
}
|
||||
return { provider, credentials };
|
||||
} else {
|
||||
logger.warn(`${logPrefix} Provider not found: ${providerId}`);
|
||||
return { provider: undefined, credentials };
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Failed to load provider by ID:`, error);
|
||||
return { provider: undefined, credentials: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
/** Result from getPhaseModelWithOverrides */
|
||||
export interface PhaseModelWithOverridesResult {
|
||||
/** The resolved phase model entry */
|
||||
phaseModel: PhaseModelEntry;
|
||||
/** Whether a project override was applied */
|
||||
isProjectOverride: boolean;
|
||||
/** The provider if providerId is set and found */
|
||||
provider: ClaudeCompatibleProvider | undefined;
|
||||
/** Credentials for API key resolution */
|
||||
credentials: Credentials | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the phase model configuration for a specific phase, applying project overrides if available.
|
||||
* Also resolves the provider if the phase model has a providerId.
|
||||
*
|
||||
* @param phase - The phase key (e.g., 'enhancementModel', 'specGenerationModel')
|
||||
* @param settingsService - Optional settings service instance (returns defaults if undefined)
|
||||
* @param projectPath - Optional project path for checking overrides
|
||||
* @param logPrefix - Prefix for log messages
|
||||
* @returns Promise resolving to phase model with provider info
|
||||
*/
|
||||
export async function getPhaseModelWithOverrides(
|
||||
phase: PhaseModelKey,
|
||||
settingsService?: SettingsService | null,
|
||||
projectPath?: string,
|
||||
logPrefix = '[SettingsHelper]'
|
||||
): Promise<PhaseModelWithOverridesResult> {
|
||||
// Handle undefined settingsService gracefully
|
||||
if (!settingsService) {
|
||||
logger.info(`${logPrefix} SettingsService not available, using default for ${phase}`);
|
||||
return {
|
||||
phaseModel: DEFAULT_PHASE_MODELS[phase] || { model: 'sonnet' },
|
||||
isProjectOverride: false,
|
||||
provider: undefined,
|
||||
credentials: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const globalSettings = await settingsService.getGlobalSettings();
|
||||
const credentials = await settingsService.getCredentials();
|
||||
const globalPhaseModels = globalSettings.phaseModels || {};
|
||||
|
||||
// Start with global phase model
|
||||
let phaseModel = globalPhaseModels[phase];
|
||||
let isProjectOverride = false;
|
||||
|
||||
// Check for project override
|
||||
if (projectPath) {
|
||||
const projectSettings = await settingsService.getProjectSettings(projectPath);
|
||||
const projectOverrides = projectSettings.phaseModelOverrides || {};
|
||||
|
||||
if (projectOverrides[phase]) {
|
||||
phaseModel = projectOverrides[phase];
|
||||
isProjectOverride = true;
|
||||
logger.debug(`${logPrefix} Using project override for ${phase}`);
|
||||
}
|
||||
}
|
||||
|
||||
// If no phase model found, use per-phase default
|
||||
if (!phaseModel) {
|
||||
phaseModel = DEFAULT_PHASE_MODELS[phase] || { model: 'sonnet' };
|
||||
logger.debug(`${logPrefix} No ${phase} configured, using default: ${phaseModel.model}`);
|
||||
}
|
||||
|
||||
// Resolve provider if providerId is set
|
||||
let provider: ClaudeCompatibleProvider | undefined;
|
||||
if (phaseModel.providerId) {
|
||||
const providers = globalSettings.claudeCompatibleProviders || [];
|
||||
provider = providers.find((p) => p.id === phaseModel.providerId);
|
||||
|
||||
if (provider) {
|
||||
if (provider.enabled === false) {
|
||||
logger.warn(
|
||||
`${logPrefix} Provider "${provider.name}" for ${phase} is disabled, falling back to direct API`
|
||||
);
|
||||
provider = undefined;
|
||||
} else {
|
||||
logger.debug(`${logPrefix} Using provider "${provider.name}" for ${phase}`);
|
||||
}
|
||||
} else {
|
||||
logger.warn(
|
||||
`${logPrefix} Provider ${phaseModel.providerId} not found for ${phase}, falling back to direct API`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
phaseModel,
|
||||
isProjectOverride,
|
||||
provider,
|
||||
credentials,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Failed to get phase model with overrides:`, error);
|
||||
// Return a safe default
|
||||
return {
|
||||
phaseModel: { model: 'sonnet' },
|
||||
isProjectOverride: false,
|
||||
provider: undefined,
|
||||
credentials: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Result from getProviderByModelId */
|
||||
export interface ProviderByModelIdResult {
|
||||
/** The provider that contains this model, or undefined if not found */
|
||||
provider: ClaudeCompatibleProvider | undefined;
|
||||
/** The model configuration if found */
|
||||
modelConfig: import('@automaker/types').ProviderModel | undefined;
|
||||
/** Credentials for API key resolution */
|
||||
credentials: Credentials | undefined;
|
||||
/** The resolved Claude model ID to use for API calls (from mapsToClaudeModel) */
|
||||
resolvedModel: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a ClaudeCompatibleProvider by one of its model IDs.
|
||||
* Searches through all enabled providers to find one that contains the specified model.
|
||||
* This is useful when you have a model string from the UI but need the provider config.
|
||||
*
|
||||
* Also resolves the `mapsToClaudeModel` field to get the actual Claude model ID to use
|
||||
* when calling the API (e.g., "GLM-4.5-Air" -> "claude-haiku-4-5").
|
||||
*
|
||||
* @param modelId - The model ID to search for (e.g., "GLM-4.7", "MiniMax-M2.1")
|
||||
* @param settingsService - Settings service instance
|
||||
* @param logPrefix - Prefix for log messages
|
||||
* @returns Promise resolving to object with provider, model config, credentials, and resolved model
|
||||
*/
|
||||
export async function getProviderByModelId(
|
||||
modelId: string,
|
||||
settingsService: SettingsService,
|
||||
logPrefix = '[SettingsHelper]'
|
||||
): Promise<ProviderByModelIdResult> {
|
||||
try {
|
||||
const globalSettings = await settingsService.getGlobalSettings();
|
||||
const credentials = await settingsService.getCredentials();
|
||||
const providers = globalSettings.claudeCompatibleProviders || [];
|
||||
|
||||
// Search through all enabled providers for this model
|
||||
for (const provider of providers) {
|
||||
// Skip disabled providers
|
||||
if (provider.enabled === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this provider has the model
|
||||
const modelConfig = provider.models?.find(
|
||||
(m) => m.id === modelId || m.id.toLowerCase() === modelId.toLowerCase()
|
||||
);
|
||||
|
||||
if (modelConfig) {
|
||||
logger.info(`${logPrefix} Found model "${modelId}" in provider "${provider.name}"`);
|
||||
|
||||
// Resolve the mapped Claude model if specified
|
||||
let resolvedModel: string | undefined;
|
||||
if (modelConfig.mapsToClaudeModel) {
|
||||
// Import resolveModelString to convert alias to full model ID
|
||||
const { resolveModelString } = await import('@automaker/model-resolver');
|
||||
resolvedModel = resolveModelString(modelConfig.mapsToClaudeModel);
|
||||
logger.info(
|
||||
`${logPrefix} Model "${modelId}" maps to Claude model "${modelConfig.mapsToClaudeModel}" -> "${resolvedModel}"`
|
||||
);
|
||||
}
|
||||
|
||||
return { provider, modelConfig, credentials, resolvedModel };
|
||||
}
|
||||
}
|
||||
|
||||
// Model not found in any provider
|
||||
logger.debug(`${logPrefix} Model "${modelId}" not found in any provider`);
|
||||
return {
|
||||
provider: undefined,
|
||||
modelConfig: undefined,
|
||||
credentials: undefined,
|
||||
resolvedModel: undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Failed to find provider by model ID:`, error);
|
||||
return {
|
||||
provider: undefined,
|
||||
modelConfig: undefined,
|
||||
credentials: undefined,
|
||||
resolvedModel: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all enabled provider models for use in model dropdowns.
|
||||
* Returns models from all enabled ClaudeCompatibleProviders.
|
||||
*
|
||||
* @param settingsService - Settings service instance
|
||||
* @param logPrefix - Prefix for log messages
|
||||
* @returns Promise resolving to array of provider models with their provider info
|
||||
*/
|
||||
export async function getAllProviderModels(
|
||||
settingsService: SettingsService,
|
||||
logPrefix = '[SettingsHelper]'
|
||||
): Promise<
|
||||
Array<{
|
||||
providerId: string;
|
||||
providerName: string;
|
||||
model: import('@automaker/types').ProviderModel;
|
||||
}>
|
||||
> {
|
||||
try {
|
||||
const globalSettings = await settingsService.getGlobalSettings();
|
||||
const providers = globalSettings.claudeCompatibleProviders || [];
|
||||
|
||||
const allModels: Array<{
|
||||
providerId: string;
|
||||
providerName: string;
|
||||
model: import('@automaker/types').ProviderModel;
|
||||
}> = [];
|
||||
|
||||
for (const provider of providers) {
|
||||
// Skip disabled providers
|
||||
if (provider.enabled === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const model of provider.models || []) {
|
||||
allModels.push({
|
||||
providerId: provider.id,
|
||||
providerName: provider.name,
|
||||
model,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`${logPrefix} Found ${allModels.length} models from ${providers.length} providers`
|
||||
);
|
||||
return allModels;
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Failed to get all provider models:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,18 +5,14 @@
|
||||
|
||||
import * as secureFs from './secure-fs.js';
|
||||
import * as path from 'path';
|
||||
import type { PRState, WorktreePRInfo } from '@automaker/types';
|
||||
|
||||
// Re-export types for backwards compatibility
|
||||
export type { PRState, WorktreePRInfo };
|
||||
|
||||
/** Maximum length for sanitized branch names in filesystem paths */
|
||||
const MAX_SANITIZED_BRANCH_PATH_LENGTH = 200;
|
||||
|
||||
export interface WorktreePRInfo {
|
||||
number: number;
|
||||
url: string;
|
||||
title: string;
|
||||
state: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface WorktreeMetadata {
|
||||
branch: string;
|
||||
createdAt: string;
|
||||
|
||||
611
apps/server/src/lib/xml-extractor.ts
Normal file
611
apps/server/src/lib/xml-extractor.ts
Normal file
@@ -0,0 +1,611 @@
|
||||
/**
|
||||
* XML Extraction Utilities
|
||||
*
|
||||
* Robust XML parsing utilities for extracting and updating sections
|
||||
* from app_spec.txt XML content. Uses regex-based parsing which is
|
||||
* sufficient for our controlled XML structure.
|
||||
*
|
||||
* Note: If more complex XML parsing is needed in the future, consider
|
||||
* using a library like 'fast-xml-parser' or 'xml2js'.
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { SpecOutput } from '@automaker/types';
|
||||
|
||||
const logger = createLogger('XmlExtractor');
|
||||
|
||||
/**
|
||||
* Represents an implemented feature extracted from XML
|
||||
*/
|
||||
export interface ImplementedFeature {
|
||||
name: string;
|
||||
description: string;
|
||||
file_locations?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Logger interface for optional custom logging
|
||||
*/
|
||||
export interface XmlExtractorLogger {
|
||||
debug: (message: string, ...args: unknown[]) => void;
|
||||
warn?: (message: string, ...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for XML extraction operations
|
||||
*/
|
||||
export interface ExtractXmlOptions {
|
||||
/** Custom logger (defaults to internal logger) */
|
||||
logger?: XmlExtractorLogger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape special XML characters
|
||||
* Handles undefined/null values by converting them to empty strings
|
||||
*/
|
||||
export function escapeXml(str: string | undefined | null): string {
|
||||
if (str == null) {
|
||||
return '';
|
||||
}
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Unescape XML entities back to regular characters
|
||||
*/
|
||||
export function unescapeXml(str: string): string {
|
||||
return str
|
||||
.replace(/'/g, "'")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/</g, '<')
|
||||
.replace(/&/g, '&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the content of a specific XML section
|
||||
*
|
||||
* @param xmlContent - The full XML content
|
||||
* @param tagName - The tag name to extract (e.g., 'implemented_features')
|
||||
* @param options - Optional extraction options
|
||||
* @returns The content between the tags, or null if not found
|
||||
*/
|
||||
export function extractXmlSection(
|
||||
xmlContent: string,
|
||||
tagName: string,
|
||||
options: ExtractXmlOptions = {}
|
||||
): string | null {
|
||||
const log = options.logger || logger;
|
||||
|
||||
const regex = new RegExp(`<${tagName}>([\\s\\S]*?)<\\/${tagName}>`, 'i');
|
||||
const match = xmlContent.match(regex);
|
||||
|
||||
if (match) {
|
||||
log.debug(`Extracted <${tagName}> section`);
|
||||
return match[1];
|
||||
}
|
||||
|
||||
log.debug(`Section <${tagName}> not found`);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all values from repeated XML elements
|
||||
*
|
||||
* @param xmlContent - The XML content to search
|
||||
* @param tagName - The tag name to extract values from
|
||||
* @param options - Optional extraction options
|
||||
* @returns Array of extracted values (unescaped)
|
||||
*/
|
||||
export function extractXmlElements(
|
||||
xmlContent: string,
|
||||
tagName: string,
|
||||
options: ExtractXmlOptions = {}
|
||||
): string[] {
|
||||
const log = options.logger || logger;
|
||||
const values: string[] = [];
|
||||
|
||||
const regex = new RegExp(`<${tagName}>([\\s\\S]*?)<\\/${tagName}>`, 'g');
|
||||
const matches = xmlContent.matchAll(regex);
|
||||
|
||||
for (const match of matches) {
|
||||
values.push(unescapeXml(match[1].trim()));
|
||||
}
|
||||
|
||||
log.debug(`Extracted ${values.length} <${tagName}> elements`);
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract implemented features from app_spec.txt XML content
|
||||
*
|
||||
* @param specContent - The full XML content of app_spec.txt
|
||||
* @param options - Optional extraction options
|
||||
* @returns Array of implemented features with name, description, and optional file_locations
|
||||
*/
|
||||
export function extractImplementedFeatures(
|
||||
specContent: string,
|
||||
options: ExtractXmlOptions = {}
|
||||
): ImplementedFeature[] {
|
||||
const log = options.logger || logger;
|
||||
const features: ImplementedFeature[] = [];
|
||||
|
||||
// Match <implemented_features>...</implemented_features> section
|
||||
const implementedSection = extractXmlSection(specContent, 'implemented_features', options);
|
||||
|
||||
if (!implementedSection) {
|
||||
log.debug('No implemented_features section found');
|
||||
return features;
|
||||
}
|
||||
|
||||
// Extract individual feature blocks
|
||||
const featureRegex = /<feature>([\s\S]*?)<\/feature>/g;
|
||||
const featureMatches = implementedSection.matchAll(featureRegex);
|
||||
|
||||
for (const featureMatch of featureMatches) {
|
||||
const featureContent = featureMatch[1];
|
||||
|
||||
// Extract name
|
||||
const nameMatch = featureContent.match(/<name>([\s\S]*?)<\/name>/);
|
||||
const name = nameMatch ? unescapeXml(nameMatch[1].trim()) : '';
|
||||
|
||||
// Extract description
|
||||
const descMatch = featureContent.match(/<description>([\s\S]*?)<\/description>/);
|
||||
const description = descMatch ? unescapeXml(descMatch[1].trim()) : '';
|
||||
|
||||
// Extract file_locations if present
|
||||
const locationsSection = extractXmlSection(featureContent, 'file_locations', options);
|
||||
const file_locations = locationsSection
|
||||
? extractXmlElements(locationsSection, 'location', options)
|
||||
: undefined;
|
||||
|
||||
if (name) {
|
||||
features.push({
|
||||
name,
|
||||
description,
|
||||
...(file_locations && file_locations.length > 0 ? { file_locations } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
log.debug(`Extracted ${features.length} implemented features`);
|
||||
return features;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract only the feature names from implemented_features section
|
||||
*
|
||||
* @param specContent - The full XML content of app_spec.txt
|
||||
* @param options - Optional extraction options
|
||||
* @returns Array of feature names
|
||||
*/
|
||||
export function extractImplementedFeatureNames(
|
||||
specContent: string,
|
||||
options: ExtractXmlOptions = {}
|
||||
): string[] {
|
||||
const features = extractImplementedFeatures(specContent, options);
|
||||
return features.map((f) => f.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate XML for a single implemented feature
|
||||
*
|
||||
* @param feature - The feature to convert to XML
|
||||
* @param indent - The base indentation level (default: 2 spaces)
|
||||
* @returns XML string for the feature
|
||||
*/
|
||||
export function featureToXml(feature: ImplementedFeature, indent: string = ' '): string {
|
||||
const i2 = indent.repeat(2);
|
||||
const i3 = indent.repeat(3);
|
||||
const i4 = indent.repeat(4);
|
||||
|
||||
let xml = `${i2}<feature>
|
||||
${i3}<name>${escapeXml(feature.name)}</name>
|
||||
${i3}<description>${escapeXml(feature.description)}</description>`;
|
||||
|
||||
if (feature.file_locations && feature.file_locations.length > 0) {
|
||||
xml += `
|
||||
${i3}<file_locations>
|
||||
${feature.file_locations.map((loc) => `${i4}<location>${escapeXml(loc)}</location>`).join('\n')}
|
||||
${i3}</file_locations>`;
|
||||
}
|
||||
|
||||
xml += `
|
||||
${i2}</feature>`;
|
||||
|
||||
return xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate XML for an array of implemented features
|
||||
*
|
||||
* @param features - Array of features to convert to XML
|
||||
* @param indent - The base indentation level (default: 2 spaces)
|
||||
* @returns XML string for the implemented_features section content
|
||||
*/
|
||||
export function featuresToXml(features: ImplementedFeature[], indent: string = ' '): string {
|
||||
return features.map((f) => featureToXml(f, indent)).join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the implemented_features section in XML content
|
||||
*
|
||||
* @param specContent - The full XML content
|
||||
* @param newFeatures - The new features to set
|
||||
* @param options - Optional extraction options
|
||||
* @returns Updated XML content with the new implemented_features section
|
||||
*/
|
||||
export function updateImplementedFeaturesSection(
|
||||
specContent: string,
|
||||
newFeatures: ImplementedFeature[],
|
||||
options: ExtractXmlOptions = {}
|
||||
): string {
|
||||
const log = options.logger || logger;
|
||||
const indent = ' ';
|
||||
|
||||
// Generate new section content
|
||||
const newSectionContent = featuresToXml(newFeatures, indent);
|
||||
|
||||
// Build the new section
|
||||
const newSection = `<implemented_features>
|
||||
${newSectionContent}
|
||||
${indent}</implemented_features>`;
|
||||
|
||||
// Check if section exists
|
||||
const sectionRegex = /<implemented_features>[\s\S]*?<\/implemented_features>/;
|
||||
|
||||
if (sectionRegex.test(specContent)) {
|
||||
log.debug('Replacing existing implemented_features section');
|
||||
return specContent.replace(sectionRegex, newSection);
|
||||
}
|
||||
|
||||
// If section doesn't exist, try to insert after core_capabilities
|
||||
const coreCapabilitiesEnd = '</core_capabilities>';
|
||||
const insertIndex = specContent.indexOf(coreCapabilitiesEnd);
|
||||
|
||||
if (insertIndex !== -1) {
|
||||
const insertPosition = insertIndex + coreCapabilitiesEnd.length;
|
||||
log.debug('Inserting implemented_features after core_capabilities');
|
||||
return (
|
||||
specContent.slice(0, insertPosition) +
|
||||
'\n\n' +
|
||||
indent +
|
||||
newSection +
|
||||
specContent.slice(insertPosition)
|
||||
);
|
||||
}
|
||||
|
||||
// As a fallback, insert before </project_specification>
|
||||
const projectSpecEnd = '</project_specification>';
|
||||
const fallbackIndex = specContent.indexOf(projectSpecEnd);
|
||||
|
||||
if (fallbackIndex !== -1) {
|
||||
log.debug('Inserting implemented_features before </project_specification>');
|
||||
return (
|
||||
specContent.slice(0, fallbackIndex) +
|
||||
indent +
|
||||
newSection +
|
||||
'\n' +
|
||||
specContent.slice(fallbackIndex)
|
||||
);
|
||||
}
|
||||
|
||||
log.warn?.('Could not find appropriate insertion point for implemented_features');
|
||||
log.debug('Could not find appropriate insertion point for implemented_features');
|
||||
return specContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new feature to the implemented_features section
|
||||
*
|
||||
* @param specContent - The full XML content
|
||||
* @param newFeature - The feature to add
|
||||
* @param options - Optional extraction options
|
||||
* @returns Updated XML content with the new feature added
|
||||
*/
|
||||
export function addImplementedFeature(
|
||||
specContent: string,
|
||||
newFeature: ImplementedFeature,
|
||||
options: ExtractXmlOptions = {}
|
||||
): string {
|
||||
const log = options.logger || logger;
|
||||
|
||||
// Extract existing features
|
||||
const existingFeatures = extractImplementedFeatures(specContent, options);
|
||||
|
||||
// Check for duplicates by name
|
||||
const isDuplicate = existingFeatures.some(
|
||||
(f) => f.name.toLowerCase() === newFeature.name.toLowerCase()
|
||||
);
|
||||
|
||||
if (isDuplicate) {
|
||||
log.debug(`Feature "${newFeature.name}" already exists, skipping`);
|
||||
return specContent;
|
||||
}
|
||||
|
||||
// Add the new feature
|
||||
const updatedFeatures = [...existingFeatures, newFeature];
|
||||
|
||||
log.debug(`Adding feature "${newFeature.name}"`);
|
||||
return updateImplementedFeaturesSection(specContent, updatedFeatures, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a feature from the implemented_features section by name
|
||||
*
|
||||
* @param specContent - The full XML content
|
||||
* @param featureName - The name of the feature to remove
|
||||
* @param options - Optional extraction options
|
||||
* @returns Updated XML content with the feature removed
|
||||
*/
|
||||
export function removeImplementedFeature(
|
||||
specContent: string,
|
||||
featureName: string,
|
||||
options: ExtractXmlOptions = {}
|
||||
): string {
|
||||
const log = options.logger || logger;
|
||||
|
||||
// Extract existing features
|
||||
const existingFeatures = extractImplementedFeatures(specContent, options);
|
||||
|
||||
// Filter out the feature to remove
|
||||
const updatedFeatures = existingFeatures.filter(
|
||||
(f) => f.name.toLowerCase() !== featureName.toLowerCase()
|
||||
);
|
||||
|
||||
if (updatedFeatures.length === existingFeatures.length) {
|
||||
log.debug(`Feature "${featureName}" not found, no changes made`);
|
||||
return specContent;
|
||||
}
|
||||
|
||||
log.debug(`Removing feature "${featureName}"`);
|
||||
return updateImplementedFeaturesSection(specContent, updatedFeatures, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing feature in the implemented_features section
|
||||
*
|
||||
* @param specContent - The full XML content
|
||||
* @param featureName - The name of the feature to update
|
||||
* @param updates - Partial updates to apply to the feature
|
||||
* @param options - Optional extraction options
|
||||
* @returns Updated XML content with the feature modified
|
||||
*/
|
||||
export function updateImplementedFeature(
|
||||
specContent: string,
|
||||
featureName: string,
|
||||
updates: Partial<ImplementedFeature>,
|
||||
options: ExtractXmlOptions = {}
|
||||
): string {
|
||||
const log = options.logger || logger;
|
||||
|
||||
// Extract existing features
|
||||
const existingFeatures = extractImplementedFeatures(specContent, options);
|
||||
|
||||
// Find and update the feature
|
||||
let found = false;
|
||||
const updatedFeatures = existingFeatures.map((f) => {
|
||||
if (f.name.toLowerCase() === featureName.toLowerCase()) {
|
||||
found = true;
|
||||
return {
|
||||
...f,
|
||||
...updates,
|
||||
// Preserve the original name if not explicitly updated
|
||||
name: updates.name ?? f.name,
|
||||
};
|
||||
}
|
||||
return f;
|
||||
});
|
||||
|
||||
if (!found) {
|
||||
log.debug(`Feature "${featureName}" not found, no changes made`);
|
||||
return specContent;
|
||||
}
|
||||
|
||||
log.debug(`Updating feature "${featureName}"`);
|
||||
return updateImplementedFeaturesSection(specContent, updatedFeatures, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a feature exists in the implemented_features section
|
||||
*
|
||||
* @param specContent - The full XML content
|
||||
* @param featureName - The name of the feature to check
|
||||
* @param options - Optional extraction options
|
||||
* @returns True if the feature exists
|
||||
*/
|
||||
export function hasImplementedFeature(
|
||||
specContent: string,
|
||||
featureName: string,
|
||||
options: ExtractXmlOptions = {}
|
||||
): boolean {
|
||||
const features = extractImplementedFeatures(specContent, options);
|
||||
return features.some((f) => f.name.toLowerCase() === featureName.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert extracted features to SpecOutput.implemented_features format
|
||||
*
|
||||
* @param features - Array of extracted features
|
||||
* @returns Features in SpecOutput format
|
||||
*/
|
||||
export function toSpecOutputFeatures(
|
||||
features: ImplementedFeature[]
|
||||
): SpecOutput['implemented_features'] {
|
||||
return features.map((f) => ({
|
||||
name: f.name,
|
||||
description: f.description,
|
||||
...(f.file_locations && f.file_locations.length > 0
|
||||
? { file_locations: f.file_locations }
|
||||
: {}),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert SpecOutput.implemented_features to ImplementedFeature format
|
||||
*
|
||||
* @param specFeatures - Features from SpecOutput
|
||||
* @returns Features in ImplementedFeature format
|
||||
*/
|
||||
export function fromSpecOutputFeatures(
|
||||
specFeatures: SpecOutput['implemented_features']
|
||||
): ImplementedFeature[] {
|
||||
return specFeatures.map((f) => ({
|
||||
name: f.name,
|
||||
description: f.description,
|
||||
...(f.file_locations && f.file_locations.length > 0
|
||||
? { file_locations: f.file_locations }
|
||||
: {}),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a roadmap phase extracted from XML
|
||||
*/
|
||||
export interface RoadmapPhase {
|
||||
name: string;
|
||||
status: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the technology stack from app_spec.txt XML content
|
||||
*
|
||||
* @param specContent - The full XML content
|
||||
* @param options - Optional extraction options
|
||||
* @returns Array of technology names
|
||||
*/
|
||||
export function extractTechnologyStack(
|
||||
specContent: string,
|
||||
options: ExtractXmlOptions = {}
|
||||
): string[] {
|
||||
const log = options.logger || logger;
|
||||
|
||||
const techSection = extractXmlSection(specContent, 'technology_stack', options);
|
||||
if (!techSection) {
|
||||
log.debug('No technology_stack section found');
|
||||
return [];
|
||||
}
|
||||
|
||||
const technologies = extractXmlElements(techSection, 'technology', options);
|
||||
log.debug(`Extracted ${technologies.length} technologies`);
|
||||
return technologies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the technology_stack section in XML content
|
||||
*
|
||||
* @param specContent - The full XML content
|
||||
* @param technologies - The new technology list
|
||||
* @param options - Optional extraction options
|
||||
* @returns Updated XML content
|
||||
*/
|
||||
export function updateTechnologyStack(
|
||||
specContent: string,
|
||||
technologies: string[],
|
||||
options: ExtractXmlOptions = {}
|
||||
): string {
|
||||
const log = options.logger || logger;
|
||||
const indent = ' ';
|
||||
const i2 = indent.repeat(2);
|
||||
|
||||
// Generate new section content
|
||||
const techXml = technologies
|
||||
.map((t) => `${i2}<technology>${escapeXml(t)}</technology>`)
|
||||
.join('\n');
|
||||
const newSection = `<technology_stack>\n${techXml}\n${indent}</technology_stack>`;
|
||||
|
||||
// Check if section exists
|
||||
const sectionRegex = /<technology_stack>[\s\S]*?<\/technology_stack>/;
|
||||
|
||||
if (sectionRegex.test(specContent)) {
|
||||
log.debug('Replacing existing technology_stack section');
|
||||
return specContent.replace(sectionRegex, newSection);
|
||||
}
|
||||
|
||||
log.debug('No technology_stack section found to update');
|
||||
return specContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract roadmap phases from app_spec.txt XML content
|
||||
*
|
||||
* @param specContent - The full XML content
|
||||
* @param options - Optional extraction options
|
||||
* @returns Array of roadmap phases
|
||||
*/
|
||||
export function extractRoadmapPhases(
|
||||
specContent: string,
|
||||
options: ExtractXmlOptions = {}
|
||||
): RoadmapPhase[] {
|
||||
const log = options.logger || logger;
|
||||
const phases: RoadmapPhase[] = [];
|
||||
|
||||
const roadmapSection = extractXmlSection(specContent, 'implementation_roadmap', options);
|
||||
if (!roadmapSection) {
|
||||
log.debug('No implementation_roadmap section found');
|
||||
return phases;
|
||||
}
|
||||
|
||||
// Extract individual phase blocks
|
||||
const phaseRegex = /<phase>([\s\S]*?)<\/phase>/g;
|
||||
const phaseMatches = roadmapSection.matchAll(phaseRegex);
|
||||
|
||||
for (const phaseMatch of phaseMatches) {
|
||||
const phaseContent = phaseMatch[1];
|
||||
|
||||
const nameMatch = phaseContent.match(/<name>([\s\S]*?)<\/name>/);
|
||||
const name = nameMatch ? unescapeXml(nameMatch[1].trim()) : '';
|
||||
|
||||
const statusMatch = phaseContent.match(/<status>([\s\S]*?)<\/status>/);
|
||||
const status = statusMatch ? unescapeXml(statusMatch[1].trim()) : 'pending';
|
||||
|
||||
const descMatch = phaseContent.match(/<description>([\s\S]*?)<\/description>/);
|
||||
const description = descMatch ? unescapeXml(descMatch[1].trim()) : undefined;
|
||||
|
||||
if (name) {
|
||||
phases.push({ name, status, description });
|
||||
}
|
||||
}
|
||||
|
||||
log.debug(`Extracted ${phases.length} roadmap phases`);
|
||||
return phases;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a roadmap phase status in XML content
|
||||
*
|
||||
* @param specContent - The full XML content
|
||||
* @param phaseName - The name of the phase to update
|
||||
* @param newStatus - The new status value
|
||||
* @param options - Optional extraction options
|
||||
* @returns Updated XML content
|
||||
*/
|
||||
export function updateRoadmapPhaseStatus(
|
||||
specContent: string,
|
||||
phaseName: string,
|
||||
newStatus: string,
|
||||
options: ExtractXmlOptions = {}
|
||||
): string {
|
||||
const log = options.logger || logger;
|
||||
|
||||
// Find the phase and update its status
|
||||
// Match the phase block containing the specific name
|
||||
const phaseRegex = new RegExp(
|
||||
`(<phase>\\s*<name>\\s*${escapeXml(phaseName)}\\s*<\\/name>\\s*<status>)[\\s\\S]*?(<\\/status>)`,
|
||||
'i'
|
||||
);
|
||||
|
||||
if (phaseRegex.test(specContent)) {
|
||||
log.debug(`Updating phase "${phaseName}" status to "${newStatus}"`);
|
||||
return specContent.replace(phaseRegex, `$1${escapeXml(newStatus)}$2`);
|
||||
}
|
||||
|
||||
log.debug(`Phase "${phaseName}" not found`);
|
||||
return specContent;
|
||||
}
|
||||
@@ -10,7 +10,21 @@ import { BaseProvider } from './base-provider.js';
|
||||
import { classifyError, getUserFriendlyErrorMessage, createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('ClaudeProvider');
|
||||
import { getThinkingTokenBudget, validateBareModelId } from '@automaker/types';
|
||||
import {
|
||||
getThinkingTokenBudget,
|
||||
validateBareModelId,
|
||||
type ClaudeApiProfile,
|
||||
type ClaudeCompatibleProvider,
|
||||
type Credentials,
|
||||
} from '@automaker/types';
|
||||
|
||||
/**
|
||||
* ProviderConfig - Union type for provider configuration
|
||||
*
|
||||
* Accepts either the legacy ClaudeApiProfile or new ClaudeCompatibleProvider.
|
||||
* Both share the same connection settings structure.
|
||||
*/
|
||||
type ProviderConfig = ClaudeApiProfile | ClaudeCompatibleProvider;
|
||||
import type {
|
||||
ExecuteOptions,
|
||||
ProviderMessage,
|
||||
@@ -21,9 +35,19 @@ import type {
|
||||
// Explicit allowlist of environment variables to pass to the SDK.
|
||||
// Only these vars are passed - nothing else from process.env leaks through.
|
||||
const ALLOWED_ENV_VARS = [
|
||||
// Authentication
|
||||
'ANTHROPIC_API_KEY',
|
||||
'ANTHROPIC_BASE_URL',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
// Endpoint configuration
|
||||
'ANTHROPIC_BASE_URL',
|
||||
'API_TIMEOUT_MS',
|
||||
// Model mappings
|
||||
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
||||
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
||||
// Traffic control
|
||||
'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
|
||||
// System vars (always from process.env)
|
||||
'PATH',
|
||||
'HOME',
|
||||
'SHELL',
|
||||
@@ -33,16 +57,132 @@ const ALLOWED_ENV_VARS = [
|
||||
'LC_ALL',
|
||||
];
|
||||
|
||||
// System vars are always passed from process.env regardless of profile
|
||||
const SYSTEM_ENV_VARS = ['PATH', 'HOME', 'SHELL', 'TERM', 'USER', 'LANG', 'LC_ALL'];
|
||||
|
||||
/**
|
||||
* Build environment for the SDK with only explicitly allowed variables
|
||||
* Check if the config is a ClaudeCompatibleProvider (new system)
|
||||
* by checking for the 'models' array property
|
||||
*/
|
||||
function buildEnv(): Record<string, string | undefined> {
|
||||
function isClaudeCompatibleProvider(config: ProviderConfig): config is ClaudeCompatibleProvider {
|
||||
return 'models' in config && Array.isArray(config.models);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build environment for the SDK with only explicitly allowed variables.
|
||||
* When a provider/profile is provided, uses its configuration (clean switch - don't inherit from process.env).
|
||||
* When no provider is provided, uses direct Anthropic API settings from process.env.
|
||||
*
|
||||
* Supports both:
|
||||
* - ClaudeCompatibleProvider (new system with models[] array)
|
||||
* - ClaudeApiProfile (legacy system with modelMappings)
|
||||
*
|
||||
* @param providerConfig - Optional provider configuration for alternative endpoint
|
||||
* @param credentials - Optional credentials object for resolving 'credentials' apiKeySource
|
||||
*/
|
||||
function buildEnv(
|
||||
providerConfig?: ProviderConfig,
|
||||
credentials?: Credentials
|
||||
): Record<string, string | undefined> {
|
||||
const env: Record<string, string | undefined> = {};
|
||||
for (const key of ALLOWED_ENV_VARS) {
|
||||
|
||||
if (providerConfig) {
|
||||
// Use provider configuration (clean switch - don't inherit non-system vars from process.env)
|
||||
logger.debug('[buildEnv] Using provider configuration:', {
|
||||
name: providerConfig.name,
|
||||
baseUrl: providerConfig.baseUrl,
|
||||
apiKeySource: providerConfig.apiKeySource ?? 'inline',
|
||||
isNewProvider: isClaudeCompatibleProvider(providerConfig),
|
||||
});
|
||||
|
||||
// Resolve API key based on source strategy
|
||||
let apiKey: string | undefined;
|
||||
const source = providerConfig.apiKeySource ?? 'inline'; // Default to inline for backwards compat
|
||||
|
||||
switch (source) {
|
||||
case 'inline':
|
||||
apiKey = providerConfig.apiKey;
|
||||
break;
|
||||
case 'env':
|
||||
apiKey = process.env.ANTHROPIC_API_KEY;
|
||||
break;
|
||||
case 'credentials':
|
||||
apiKey = credentials?.apiKeys?.anthropic;
|
||||
break;
|
||||
}
|
||||
|
||||
// Warn if no API key found
|
||||
if (!apiKey) {
|
||||
logger.warn(`No API key found for provider "${providerConfig.name}" with source "${source}"`);
|
||||
}
|
||||
|
||||
// Authentication
|
||||
if (providerConfig.useAuthToken) {
|
||||
env['ANTHROPIC_AUTH_TOKEN'] = apiKey;
|
||||
} else {
|
||||
env['ANTHROPIC_API_KEY'] = apiKey;
|
||||
}
|
||||
|
||||
// Endpoint configuration
|
||||
env['ANTHROPIC_BASE_URL'] = providerConfig.baseUrl;
|
||||
logger.debug(`[buildEnv] Set ANTHROPIC_BASE_URL to: ${providerConfig.baseUrl}`);
|
||||
|
||||
if (providerConfig.timeoutMs) {
|
||||
env['API_TIMEOUT_MS'] = String(providerConfig.timeoutMs);
|
||||
}
|
||||
|
||||
// Model mappings - only for legacy ClaudeApiProfile
|
||||
// For ClaudeCompatibleProvider, the model is passed directly (no mapping needed)
|
||||
if (!isClaudeCompatibleProvider(providerConfig) && providerConfig.modelMappings) {
|
||||
if (providerConfig.modelMappings.haiku) {
|
||||
env['ANTHROPIC_DEFAULT_HAIKU_MODEL'] = providerConfig.modelMappings.haiku;
|
||||
}
|
||||
if (providerConfig.modelMappings.sonnet) {
|
||||
env['ANTHROPIC_DEFAULT_SONNET_MODEL'] = providerConfig.modelMappings.sonnet;
|
||||
}
|
||||
if (providerConfig.modelMappings.opus) {
|
||||
env['ANTHROPIC_DEFAULT_OPUS_MODEL'] = providerConfig.modelMappings.opus;
|
||||
}
|
||||
}
|
||||
|
||||
// Traffic control
|
||||
if (providerConfig.disableNonessentialTraffic) {
|
||||
env['CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC'] = '1';
|
||||
}
|
||||
} else {
|
||||
// Use direct Anthropic API - pass through credentials or environment variables
|
||||
// This supports:
|
||||
// 1. API Key mode: ANTHROPIC_API_KEY from credentials (UI settings) or env
|
||||
// 2. Claude Max plan: Uses CLI OAuth auth (SDK handles this automatically)
|
||||
// 3. Custom endpoints via ANTHROPIC_BASE_URL env var (backward compatibility)
|
||||
//
|
||||
// Priority: credentials file (UI settings) -> environment variable
|
||||
// Note: Only auth and endpoint vars are passed. Model mappings and traffic
|
||||
// control are NOT passed (those require a profile for explicit configuration).
|
||||
if (credentials?.apiKeys?.anthropic) {
|
||||
env['ANTHROPIC_API_KEY'] = credentials.apiKeys.anthropic;
|
||||
} else if (process.env.ANTHROPIC_API_KEY) {
|
||||
env['ANTHROPIC_API_KEY'] = process.env.ANTHROPIC_API_KEY;
|
||||
}
|
||||
// If using Claude Max plan via CLI auth, the SDK handles auth automatically
|
||||
// when no API key is provided. We don't set ANTHROPIC_AUTH_TOKEN here
|
||||
// unless it was explicitly set in process.env (rare edge case).
|
||||
if (process.env.ANTHROPIC_AUTH_TOKEN) {
|
||||
env['ANTHROPIC_AUTH_TOKEN'] = process.env.ANTHROPIC_AUTH_TOKEN;
|
||||
}
|
||||
// Pass through ANTHROPIC_BASE_URL if set in environment (backward compatibility)
|
||||
if (process.env.ANTHROPIC_BASE_URL) {
|
||||
env['ANTHROPIC_BASE_URL'] = process.env.ANTHROPIC_BASE_URL;
|
||||
}
|
||||
}
|
||||
|
||||
// Always add system vars from process.env
|
||||
for (const key of SYSTEM_ENV_VARS) {
|
||||
if (process.env[key]) {
|
||||
env[key] = process.env[key];
|
||||
}
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
@@ -70,8 +210,15 @@ export class ClaudeProvider extends BaseProvider {
|
||||
conversationHistory,
|
||||
sdkSessionId,
|
||||
thinkingLevel,
|
||||
claudeApiProfile,
|
||||
claudeCompatibleProvider,
|
||||
credentials,
|
||||
} = options;
|
||||
|
||||
// Determine which provider config to use
|
||||
// claudeCompatibleProvider takes precedence over claudeApiProfile
|
||||
const providerConfig = claudeCompatibleProvider || claudeApiProfile;
|
||||
|
||||
// Convert thinking level to token budget
|
||||
const maxThinkingTokens = getThinkingTokenBudget(thinkingLevel);
|
||||
|
||||
@@ -82,7 +229,9 @@ export class ClaudeProvider extends BaseProvider {
|
||||
maxTurns,
|
||||
cwd,
|
||||
// Pass only explicitly allowed environment variables to SDK
|
||||
env: buildEnv(),
|
||||
// When a provider is active, uses provider settings (clean switch)
|
||||
// When no provider, uses direct Anthropic API (from process.env or CLI OAuth)
|
||||
env: buildEnv(providerConfig, credentials),
|
||||
// Pass through allowedTools if provided by caller (decided by sdk-options.ts)
|
||||
...(allowedTools && { allowedTools }),
|
||||
// AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation
|
||||
@@ -127,6 +276,18 @@ export class ClaudeProvider extends BaseProvider {
|
||||
promptPayload = prompt;
|
||||
}
|
||||
|
||||
// Log the environment being passed to the SDK for debugging
|
||||
const envForSdk = sdkOptions.env as Record<string, string | undefined>;
|
||||
logger.debug('[ClaudeProvider] SDK Configuration:', {
|
||||
model: sdkOptions.model,
|
||||
baseUrl: envForSdk?.['ANTHROPIC_BASE_URL'] || '(default Anthropic API)',
|
||||
hasApiKey: !!envForSdk?.['ANTHROPIC_API_KEY'],
|
||||
hasAuthToken: !!envForSdk?.['ANTHROPIC_AUTH_TOKEN'],
|
||||
providerName: providerConfig?.name || '(direct Anthropic)',
|
||||
maxTurns: sdkOptions.maxTurns,
|
||||
maxThinkingTokens: sdkOptions.maxThinkingTokens,
|
||||
});
|
||||
|
||||
// Execute via Claude Agent SDK
|
||||
try {
|
||||
const stream = query({ prompt: promptPayload, options: sdkOptions });
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
type SubprocessOptions,
|
||||
type WslCliResult,
|
||||
} from '@automaker/platform';
|
||||
import { calculateReasoningTimeout } from '@automaker/types';
|
||||
import { createLogger, isAbortError } from '@automaker/utils';
|
||||
import { execSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
@@ -107,6 +108,15 @@ export interface CliDetectionResult {
|
||||
// Create logger for CLI operations
|
||||
const cliLogger = createLogger('CliProvider');
|
||||
|
||||
/**
|
||||
* Base timeout for CLI operations in milliseconds.
|
||||
* CLI tools have longer startup and processing times compared to direct API calls,
|
||||
* so we use a higher base timeout (120s) than the default provider timeout (30s).
|
||||
* This is multiplied by reasoning effort multipliers when applicable.
|
||||
* @see calculateReasoningTimeout from @automaker/types
|
||||
*/
|
||||
const CLI_BASE_TIMEOUT_MS = 120000;
|
||||
|
||||
/**
|
||||
* Abstract base class for CLI-based providers
|
||||
*
|
||||
@@ -450,6 +460,10 @@ export abstract class CliProvider extends BaseProvider {
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate dynamic timeout based on reasoning effort.
|
||||
// This addresses GitHub issue #530 where reasoning models with 'xhigh' effort would timeout.
|
||||
const timeout = calculateReasoningTimeout(options.reasoningEffort, CLI_BASE_TIMEOUT_MS);
|
||||
|
||||
// WSL strategy
|
||||
if (this.useWsl && this.wslCliPath) {
|
||||
const wslCwd = windowsToWslPath(cwd);
|
||||
@@ -473,7 +487,7 @@ export abstract class CliProvider extends BaseProvider {
|
||||
cwd, // Windows cwd for spawn
|
||||
env: filteredEnv,
|
||||
abortController: options.abortController,
|
||||
timeout: 120000, // CLI operations may take longer
|
||||
timeout,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -488,7 +502,7 @@ export abstract class CliProvider extends BaseProvider {
|
||||
cwd,
|
||||
env: filteredEnv,
|
||||
abortController: options.abortController,
|
||||
timeout: 120000,
|
||||
timeout,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -501,7 +515,7 @@ export abstract class CliProvider extends BaseProvider {
|
||||
cwd,
|
||||
env: filteredEnv,
|
||||
abortController: options.abortController,
|
||||
timeout: 120000,
|
||||
timeout,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ import {
|
||||
CODEX_MODEL_MAP,
|
||||
supportsReasoningEffort,
|
||||
validateBareModelId,
|
||||
calculateReasoningTimeout,
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
type CodexApprovalPolicy,
|
||||
type CodexSandboxMode,
|
||||
type CodexAuthStatus,
|
||||
@@ -91,7 +93,19 @@ const CODEX_ITEM_TYPES = {
|
||||
const SYSTEM_PROMPT_LABEL = 'System instructions';
|
||||
const HISTORY_HEADER = 'Current request:\n';
|
||||
const TEXT_ENCODING = 'utf-8';
|
||||
const DEFAULT_TIMEOUT_MS = 30000;
|
||||
/**
|
||||
* Default timeout for Codex CLI operations in milliseconds.
|
||||
* This is the "no output" timeout - if the CLI doesn't produce any JSONL output
|
||||
* for this duration, the process is killed. For reasoning models with high
|
||||
* reasoning effort, this timeout is dynamically extended via calculateReasoningTimeout().
|
||||
*
|
||||
* For feature generation (which can generate 50+ features), we use a much longer
|
||||
* base timeout (5 minutes) since Codex models are slower at generating large JSON responses.
|
||||
*
|
||||
* @see calculateReasoningTimeout from @automaker/types
|
||||
*/
|
||||
const CODEX_CLI_TIMEOUT_MS = DEFAULT_TIMEOUT_MS;
|
||||
const CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS = 300000; // 5 minutes for feature generation
|
||||
const CONTEXT_WINDOW_256K = 256000;
|
||||
const MAX_OUTPUT_32K = 32000;
|
||||
const MAX_OUTPUT_16K = 16000;
|
||||
@@ -814,13 +828,26 @@ export class CodexProvider extends BaseProvider {
|
||||
envOverrides[OPENAI_API_KEY_ENV] = executionPlan.openAiApiKey;
|
||||
}
|
||||
|
||||
// Calculate dynamic timeout based on reasoning effort.
|
||||
// Higher reasoning effort (e.g., 'xhigh' for "xtra thinking" mode) requires more time
|
||||
// for the model to generate reasoning tokens before producing output.
|
||||
// This fixes GitHub issue #530 where features would get stuck with reasoning models.
|
||||
//
|
||||
// For feature generation with 'xhigh', use the extended 5-minute base timeout
|
||||
// since generating 50+ features takes significantly longer than normal operations.
|
||||
const baseTimeout =
|
||||
options.reasoningEffort === 'xhigh'
|
||||
? CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS
|
||||
: CODEX_CLI_TIMEOUT_MS;
|
||||
const timeout = calculateReasoningTimeout(options.reasoningEffort, baseTimeout);
|
||||
|
||||
const stream = spawnJSONLProcess({
|
||||
command: commandPath,
|
||||
args,
|
||||
cwd: options.cwd,
|
||||
env: envOverrides,
|
||||
abortController: options.abortController,
|
||||
timeout: DEFAULT_TIMEOUT_MS,
|
||||
timeout,
|
||||
stdinData: promptText, // Pass prompt via stdin
|
||||
});
|
||||
|
||||
|
||||
942
apps/server/src/providers/copilot-provider.ts
Normal file
942
apps/server/src/providers/copilot-provider.ts
Normal file
@@ -0,0 +1,942 @@
|
||||
/**
|
||||
* Copilot Provider - Executes queries using the GitHub Copilot SDK
|
||||
*
|
||||
* Uses the official @github/copilot-sdk for:
|
||||
* - Session management and streaming responses
|
||||
* - GitHub OAuth authentication (via gh CLI)
|
||||
* - Tool call handling and permission management
|
||||
* - Runtime model discovery
|
||||
*
|
||||
* Based on https://github.com/github/copilot-sdk
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { CliProvider, type CliSpawnConfig, type CliErrorInfo } from './cli-provider.js';
|
||||
import type {
|
||||
ProviderConfig,
|
||||
ExecuteOptions,
|
||||
ProviderMessage,
|
||||
InstallationStatus,
|
||||
ModelDefinition,
|
||||
} from './types.js';
|
||||
// Note: validateBareModelId is not used because Copilot's bare model IDs
|
||||
// legitimately contain prefixes like claude-, gemini-, gpt-
|
||||
import {
|
||||
COPILOT_MODEL_MAP,
|
||||
type CopilotAuthStatus,
|
||||
type CopilotRuntimeModel,
|
||||
} from '@automaker/types';
|
||||
import { createLogger, isAbortError } from '@automaker/utils';
|
||||
import { CopilotClient, type PermissionRequest } from '@github/copilot-sdk';
|
||||
import {
|
||||
normalizeTodos,
|
||||
normalizeFilePathInput,
|
||||
normalizeCommandInput,
|
||||
normalizePatternInput,
|
||||
} from './tool-normalization.js';
|
||||
|
||||
// Create logger for this module
|
||||
const logger = createLogger('CopilotProvider');
|
||||
|
||||
// Default bare model (without copilot- prefix) for SDK calls
|
||||
const DEFAULT_BARE_MODEL = 'claude-sonnet-4.5';
|
||||
|
||||
// =============================================================================
|
||||
// SDK Event Types (from @github/copilot-sdk)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* SDK session event data types
|
||||
*/
|
||||
interface SdkEvent {
|
||||
type: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
interface SdkMessageEvent extends SdkEvent {
|
||||
type: 'assistant.message';
|
||||
data: {
|
||||
content: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Note: SdkMessageDeltaEvent is not used - we skip delta events to reduce noise
|
||||
// The final assistant.message event contains the complete content
|
||||
|
||||
interface SdkToolExecutionStartEvent extends SdkEvent {
|
||||
type: 'tool.execution_start';
|
||||
data: {
|
||||
toolName: string;
|
||||
toolCallId: string;
|
||||
input?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
interface SdkToolExecutionEndEvent extends SdkEvent {
|
||||
type: 'tool.execution_end';
|
||||
data: {
|
||||
toolName: string;
|
||||
toolCallId: string;
|
||||
result?: string;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface SdkSessionIdleEvent extends SdkEvent {
|
||||
type: 'session.idle';
|
||||
}
|
||||
|
||||
interface SdkSessionErrorEvent extends SdkEvent {
|
||||
type: 'session.error';
|
||||
data: {
|
||||
message: string;
|
||||
code?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Error Codes
|
||||
// =============================================================================
|
||||
|
||||
export enum CopilotErrorCode {
|
||||
NOT_INSTALLED = 'COPILOT_NOT_INSTALLED',
|
||||
NOT_AUTHENTICATED = 'COPILOT_NOT_AUTHENTICATED',
|
||||
RATE_LIMITED = 'COPILOT_RATE_LIMITED',
|
||||
MODEL_UNAVAILABLE = 'COPILOT_MODEL_UNAVAILABLE',
|
||||
NETWORK_ERROR = 'COPILOT_NETWORK_ERROR',
|
||||
PROCESS_CRASHED = 'COPILOT_PROCESS_CRASHED',
|
||||
TIMEOUT = 'COPILOT_TIMEOUT',
|
||||
CLI_ERROR = 'COPILOT_CLI_ERROR',
|
||||
SDK_ERROR = 'COPILOT_SDK_ERROR',
|
||||
UNKNOWN = 'COPILOT_UNKNOWN_ERROR',
|
||||
}
|
||||
|
||||
export interface CopilotError extends Error {
|
||||
code: CopilotErrorCode;
|
||||
recoverable: boolean;
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tool Name Normalization
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Copilot SDK tool name to standard tool name mapping
|
||||
*
|
||||
* Maps Copilot CLI tool names to our standard tool names for consistent UI display.
|
||||
* Tool names are case-insensitive (normalized to lowercase before lookup).
|
||||
*/
|
||||
const COPILOT_TOOL_NAME_MAP: Record<string, string> = {
|
||||
// File operations
|
||||
read_file: 'Read',
|
||||
read: 'Read',
|
||||
view: 'Read', // Copilot uses 'view' for reading files
|
||||
read_many_files: 'Read',
|
||||
write_file: 'Write',
|
||||
write: 'Write',
|
||||
create_file: 'Write',
|
||||
edit_file: 'Edit',
|
||||
edit: 'Edit',
|
||||
replace: 'Edit',
|
||||
patch: 'Edit',
|
||||
// Shell operations
|
||||
run_shell: 'Bash',
|
||||
run_shell_command: 'Bash',
|
||||
shell: 'Bash',
|
||||
bash: 'Bash',
|
||||
execute: 'Bash',
|
||||
terminal: 'Bash',
|
||||
// Search operations
|
||||
search: 'Grep',
|
||||
grep: 'Grep',
|
||||
search_file_content: 'Grep',
|
||||
find_files: 'Glob',
|
||||
glob: 'Glob',
|
||||
list_dir: 'Ls',
|
||||
list_directory: 'Ls',
|
||||
ls: 'Ls',
|
||||
// Web operations
|
||||
web_fetch: 'WebFetch',
|
||||
fetch: 'WebFetch',
|
||||
web_search: 'WebSearch',
|
||||
search_web: 'WebSearch',
|
||||
google_web_search: 'WebSearch',
|
||||
// Todo operations
|
||||
todo_write: 'TodoWrite',
|
||||
write_todos: 'TodoWrite',
|
||||
update_todos: 'TodoWrite',
|
||||
// Planning/intent operations (Copilot-specific)
|
||||
report_intent: 'ReportIntent', // Keep as-is, it's a planning tool
|
||||
think: 'Think',
|
||||
plan: 'Plan',
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize Copilot tool names to standard tool names
|
||||
*/
|
||||
function normalizeCopilotToolName(copilotToolName: string): string {
|
||||
const lowerName = copilotToolName.toLowerCase();
|
||||
return COPILOT_TOOL_NAME_MAP[lowerName] || copilotToolName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize Copilot tool input parameters to standard format
|
||||
*
|
||||
* Maps Copilot's parameter names to our standard parameter names.
|
||||
* Uses shared utilities from tool-normalization.ts for common normalizations.
|
||||
*/
|
||||
function normalizeCopilotToolInput(
|
||||
toolName: string,
|
||||
input: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
const normalizedName = normalizeCopilotToolName(toolName);
|
||||
|
||||
// Normalize todo_write / write_todos: ensure proper format
|
||||
if (normalizedName === 'TodoWrite' && Array.isArray(input.todos)) {
|
||||
return { todos: normalizeTodos(input.todos) };
|
||||
}
|
||||
|
||||
// Normalize file path parameters for Read/Write/Edit tools
|
||||
if (normalizedName === 'Read' || normalizedName === 'Write' || normalizedName === 'Edit') {
|
||||
return normalizeFilePathInput(input);
|
||||
}
|
||||
|
||||
// Normalize shell command parameters for Bash tool
|
||||
if (normalizedName === 'Bash') {
|
||||
return normalizeCommandInput(input);
|
||||
}
|
||||
|
||||
// Normalize search parameters for Grep tool
|
||||
if (normalizedName === 'Grep') {
|
||||
return normalizePatternInput(input);
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
/**
|
||||
* CopilotProvider - Integrates GitHub Copilot SDK as an AI provider
|
||||
*
|
||||
* Features:
|
||||
* - GitHub OAuth authentication
|
||||
* - SDK-based session management
|
||||
* - Runtime model discovery
|
||||
* - Tool call normalization
|
||||
* - Per-execution working directory support
|
||||
*/
|
||||
export class CopilotProvider extends CliProvider {
|
||||
private runtimeModels: CopilotRuntimeModel[] | null = null;
|
||||
|
||||
constructor(config: ProviderConfig = {}) {
|
||||
super(config);
|
||||
// Trigger CLI detection on construction
|
||||
this.ensureCliDetected();
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// CliProvider Abstract Method Implementations
|
||||
// ==========================================================================
|
||||
|
||||
getName(): string {
|
||||
return 'copilot';
|
||||
}
|
||||
|
||||
getCliName(): string {
|
||||
return 'copilot';
|
||||
}
|
||||
|
||||
getSpawnConfig(): CliSpawnConfig {
|
||||
return {
|
||||
windowsStrategy: 'npx', // Copilot CLI can be run via npx
|
||||
npxPackage: '@github/copilot', // Official GitHub Copilot CLI package
|
||||
commonPaths: {
|
||||
linux: [
|
||||
path.join(os.homedir(), '.local/bin/copilot'),
|
||||
'/usr/local/bin/copilot',
|
||||
path.join(os.homedir(), '.npm-global/bin/copilot'),
|
||||
],
|
||||
darwin: [
|
||||
path.join(os.homedir(), '.local/bin/copilot'),
|
||||
'/usr/local/bin/copilot',
|
||||
'/opt/homebrew/bin/copilot',
|
||||
path.join(os.homedir(), '.npm-global/bin/copilot'),
|
||||
],
|
||||
win32: [
|
||||
path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'copilot.cmd'),
|
||||
path.join(os.homedir(), '.npm-global', 'copilot.cmd'),
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract prompt text from ExecuteOptions
|
||||
*
|
||||
* Note: CopilotProvider does not yet support vision/image inputs.
|
||||
* If non-text content is provided, an error is thrown.
|
||||
*/
|
||||
private extractPromptText(options: ExecuteOptions): string {
|
||||
if (typeof options.prompt === 'string') {
|
||||
return options.prompt;
|
||||
} else if (Array.isArray(options.prompt)) {
|
||||
// Check for non-text content (images, etc.) which we don't support yet
|
||||
const hasNonText = options.prompt.some((p) => p.type !== 'text');
|
||||
if (hasNonText) {
|
||||
throw new Error(
|
||||
'CopilotProvider does not yet support non-text prompt parts (e.g., images). ' +
|
||||
'Please use text-only prompts or switch to a provider that supports vision.'
|
||||
);
|
||||
}
|
||||
return options.prompt
|
||||
.filter((p) => p.type === 'text' && p.text)
|
||||
.map((p) => p.text)
|
||||
.join('\n');
|
||||
} else {
|
||||
throw new Error('Invalid prompt format');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Not used with SDK approach - kept for interface compatibility
|
||||
*/
|
||||
buildCliArgs(_options: ExecuteOptions): string[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert SDK event to AutoMaker ProviderMessage format
|
||||
*/
|
||||
normalizeEvent(event: unknown): ProviderMessage | null {
|
||||
const sdkEvent = event as SdkEvent;
|
||||
|
||||
switch (sdkEvent.type) {
|
||||
case 'assistant.message': {
|
||||
const messageEvent = sdkEvent as SdkMessageEvent;
|
||||
return {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: messageEvent.data.content }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'assistant.message_delta': {
|
||||
// Skip delta events - they create too much noise
|
||||
// The final assistant.message event has the complete content
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'tool.execution_start': {
|
||||
const toolEvent = sdkEvent as SdkToolExecutionStartEvent;
|
||||
const normalizedName = normalizeCopilotToolName(toolEvent.data.toolName);
|
||||
const normalizedInput = toolEvent.data.input
|
||||
? normalizeCopilotToolInput(toolEvent.data.toolName, toolEvent.data.input)
|
||||
: {};
|
||||
|
||||
return {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
name: normalizedName,
|
||||
tool_use_id: toolEvent.data.toolCallId,
|
||||
input: normalizedInput,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'tool.execution_end': {
|
||||
const toolResultEvent = sdkEvent as SdkToolExecutionEndEvent;
|
||||
const isError = !!toolResultEvent.data.error;
|
||||
const content = isError
|
||||
? `[ERROR] ${toolResultEvent.data.error}`
|
||||
: toolResultEvent.data.result || '';
|
||||
|
||||
return {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: toolResultEvent.data.toolCallId,
|
||||
content,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'session.idle': {
|
||||
logger.debug('Copilot session idle');
|
||||
return {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
};
|
||||
}
|
||||
|
||||
case 'session.error': {
|
||||
const errorEvent = sdkEvent as SdkSessionErrorEvent;
|
||||
return {
|
||||
type: 'error',
|
||||
error: errorEvent.data.message || 'Unknown error',
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
logger.debug(`Unknown Copilot SDK event type: ${sdkEvent.type}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// CliProvider Overrides
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Override error mapping for Copilot-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') ||
|
||||
lower.includes('login required') ||
|
||||
lower.includes('authentication required') ||
|
||||
lower.includes('github login')
|
||||
) {
|
||||
return {
|
||||
code: CopilotErrorCode.NOT_AUTHENTICATED,
|
||||
message: 'GitHub Copilot is not authenticated',
|
||||
recoverable: true,
|
||||
suggestion: 'Run "gh auth login" or "copilot auth login" to authenticate with GitHub',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
lower.includes('rate limit') ||
|
||||
lower.includes('too many requests') ||
|
||||
lower.includes('429') ||
|
||||
lower.includes('quota exceeded')
|
||||
) {
|
||||
return {
|
||||
code: CopilotErrorCode.RATE_LIMITED,
|
||||
message: 'Copilot API rate limit exceeded',
|
||||
recoverable: true,
|
||||
suggestion: 'Wait a few minutes and try again',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
lower.includes('model not available') ||
|
||||
lower.includes('invalid model') ||
|
||||
lower.includes('unknown model') ||
|
||||
lower.includes('model not found') ||
|
||||
(lower.includes('not found') && lower.includes('404'))
|
||||
) {
|
||||
return {
|
||||
code: CopilotErrorCode.MODEL_UNAVAILABLE,
|
||||
message: 'Requested model is not available',
|
||||
recoverable: true,
|
||||
suggestion: `Try using "${DEFAULT_BARE_MODEL}" or select a different model`,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
lower.includes('network') ||
|
||||
lower.includes('connection') ||
|
||||
lower.includes('econnrefused') ||
|
||||
lower.includes('timeout')
|
||||
) {
|
||||
return {
|
||||
code: CopilotErrorCode.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: CopilotErrorCode.PROCESS_CRASHED,
|
||||
message: 'Copilot CLI process was terminated',
|
||||
recoverable: true,
|
||||
suggestion: 'The process may have run out of memory. Try a simpler task.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
code: CopilotErrorCode.UNKNOWN,
|
||||
message: stderr || `Copilot CLI exited with code ${exitCode}`,
|
||||
recoverable: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Override install instructions for Copilot-specific guidance
|
||||
*/
|
||||
protected getInstallInstructions(): string {
|
||||
return 'Install with: npm install -g @github/copilot (or visit https://github.com/github/copilot)';
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a prompt using Copilot SDK with real-time streaming
|
||||
*
|
||||
* Creates a new CopilotClient for each execution with the correct working directory.
|
||||
* Streams tool execution events in real-time for UI display.
|
||||
*/
|
||||
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
|
||||
this.ensureCliDetected();
|
||||
|
||||
// Note: We don't use validateBareModelId here because Copilot's model IDs
|
||||
// legitimately contain prefixes like claude-, gemini-, gpt- which are the
|
||||
// actual model names from the Copilot CLI. We only need to ensure the
|
||||
// copilot- prefix has been stripped by the ProviderFactory.
|
||||
if (options.model?.startsWith('copilot-')) {
|
||||
throw new Error(
|
||||
`[CopilotProvider] Model ID should not have 'copilot-' prefix. Got: '${options.model}'. ` +
|
||||
`The ProviderFactory should strip this prefix before passing to the provider.`
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.cliPath) {
|
||||
throw this.createError(
|
||||
CopilotErrorCode.NOT_INSTALLED,
|
||||
'Copilot CLI is not installed',
|
||||
true,
|
||||
this.getInstallInstructions()
|
||||
);
|
||||
}
|
||||
|
||||
const promptText = this.extractPromptText(options);
|
||||
const bareModel = options.model || DEFAULT_BARE_MODEL;
|
||||
const workingDirectory = options.cwd || process.cwd();
|
||||
|
||||
logger.debug(
|
||||
`CopilotProvider.executeQuery called with model: "${bareModel}", cwd: "${workingDirectory}"`
|
||||
);
|
||||
logger.debug(`Prompt length: ${promptText.length} characters`);
|
||||
|
||||
// Create a client for this execution with the correct working directory
|
||||
const client = new CopilotClient({
|
||||
logLevel: 'warning',
|
||||
autoRestart: false,
|
||||
cwd: workingDirectory,
|
||||
});
|
||||
|
||||
// Use an async queue to bridge callback-based SDK events to async generator
|
||||
const eventQueue: SdkEvent[] = [];
|
||||
let resolveWaiting: (() => void) | null = null;
|
||||
let sessionComplete = false;
|
||||
let sessionError: Error | null = null;
|
||||
|
||||
const pushEvent = (event: SdkEvent) => {
|
||||
eventQueue.push(event);
|
||||
if (resolveWaiting) {
|
||||
resolveWaiting();
|
||||
resolveWaiting = null;
|
||||
}
|
||||
};
|
||||
|
||||
const waitForEvent = (): Promise<void> => {
|
||||
if (eventQueue.length > 0 || sessionComplete) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
resolveWaiting = resolve;
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
await client.start();
|
||||
logger.debug(`CopilotClient started with cwd: ${workingDirectory}`);
|
||||
|
||||
// Create session with streaming enabled for real-time events
|
||||
const session = await client.createSession({
|
||||
model: bareModel,
|
||||
streaming: true,
|
||||
// AUTONOMOUS MODE: Auto-approve all permission requests.
|
||||
// AutoMaker is designed for fully autonomous AI agent operation.
|
||||
// Security boundary is provided by Docker containerization (see CLAUDE.md).
|
||||
// User is warned about this at app startup.
|
||||
onPermissionRequest: async (
|
||||
request: PermissionRequest
|
||||
): Promise<{ kind: 'approved' } | { kind: 'denied-interactively-by-user' }> => {
|
||||
logger.debug(`Permission request: ${request.kind}`);
|
||||
return { kind: 'approved' };
|
||||
},
|
||||
});
|
||||
|
||||
const sessionId = session.sessionId;
|
||||
logger.debug(`Session created: ${sessionId}`);
|
||||
|
||||
// Set up event handler to push events to queue
|
||||
session.on((event: SdkEvent) => {
|
||||
logger.debug(`SDK event: ${event.type}`);
|
||||
|
||||
if (event.type === 'session.idle') {
|
||||
sessionComplete = true;
|
||||
pushEvent(event);
|
||||
} else if (event.type === 'session.error') {
|
||||
const errorEvent = event as SdkSessionErrorEvent;
|
||||
sessionError = new Error(errorEvent.data.message);
|
||||
sessionComplete = true;
|
||||
pushEvent(event);
|
||||
} else {
|
||||
// Push all other events (tool.execution_start, tool.execution_end, assistant.message, etc.)
|
||||
pushEvent(event);
|
||||
}
|
||||
});
|
||||
|
||||
// Send the prompt (non-blocking)
|
||||
await session.send({ prompt: promptText });
|
||||
|
||||
// Process events as they arrive
|
||||
while (!sessionComplete || eventQueue.length > 0) {
|
||||
await waitForEvent();
|
||||
|
||||
// Check for errors first (before processing events to avoid race condition)
|
||||
if (sessionError) {
|
||||
await session.destroy();
|
||||
await client.stop();
|
||||
throw sessionError;
|
||||
}
|
||||
|
||||
// Process all queued events
|
||||
while (eventQueue.length > 0) {
|
||||
const event = eventQueue.shift()!;
|
||||
const normalized = this.normalizeEvent(event);
|
||||
if (normalized) {
|
||||
// Add session_id if not present
|
||||
if (!normalized.session_id) {
|
||||
normalized.session_id = sessionId;
|
||||
}
|
||||
yield normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
await session.destroy();
|
||||
await client.stop();
|
||||
logger.debug('CopilotClient stopped successfully');
|
||||
} catch (error) {
|
||||
// Ensure client is stopped on error
|
||||
try {
|
||||
await client.stop();
|
||||
} catch (cleanupError) {
|
||||
// Log but don't throw cleanup errors - the original error is more important
|
||||
logger.debug(`Failed to stop client during cleanup: ${cleanupError}`);
|
||||
}
|
||||
|
||||
if (isAbortError(error)) {
|
||||
logger.debug('Query aborted');
|
||||
return;
|
||||
}
|
||||
|
||||
// Map errors to CopilotError
|
||||
if (error instanceof Error) {
|
||||
logger.error(`Copilot SDK error: ${error.message}`);
|
||||
const errorInfo = this.mapError(error.message, null);
|
||||
throw this.createError(
|
||||
errorInfo.code as CopilotErrorCode,
|
||||
errorInfo.message,
|
||||
errorInfo.recoverable,
|
||||
errorInfo.suggestion
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Copilot-Specific Methods
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Create a CopilotError with details
|
||||
*/
|
||||
private createError(
|
||||
code: CopilotErrorCode,
|
||||
message: string,
|
||||
recoverable: boolean = false,
|
||||
suggestion?: string
|
||||
): CopilotError {
|
||||
const error = new Error(message) as CopilotError;
|
||||
error.code = code;
|
||||
error.recoverable = recoverable;
|
||||
error.suggestion = suggestion;
|
||||
error.name = 'CopilotError';
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Copilot CLI version
|
||||
*/
|
||||
async getVersion(): Promise<string | null> {
|
||||
this.ensureCliDetected();
|
||||
if (!this.cliPath) return null;
|
||||
|
||||
try {
|
||||
const result = execSync(`"${this.cliPath}" --version`, {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
stdio: 'pipe',
|
||||
}).trim();
|
||||
return result;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check authentication status
|
||||
*
|
||||
* Uses GitHub CLI (gh) to check Copilot authentication status.
|
||||
* The Copilot CLI relies on gh auth for authentication.
|
||||
*/
|
||||
async checkAuth(): Promise<CopilotAuthStatus> {
|
||||
this.ensureCliDetected();
|
||||
if (!this.cliPath) {
|
||||
logger.debug('checkAuth: CLI not found');
|
||||
return { authenticated: false, method: 'none' };
|
||||
}
|
||||
|
||||
logger.debug('checkAuth: Starting credential check');
|
||||
|
||||
// Try to check GitHub CLI authentication status first
|
||||
// The Copilot CLI uses gh auth for authentication
|
||||
try {
|
||||
const ghStatus = execSync('gh auth status --hostname github.com', {
|
||||
encoding: 'utf8',
|
||||
timeout: 10000,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
logger.debug(`checkAuth: gh auth status output: ${ghStatus.substring(0, 200)}`);
|
||||
|
||||
// Parse gh auth status output
|
||||
const loggedInMatch = ghStatus.match(/Logged in to github\.com account (\S+)/);
|
||||
if (loggedInMatch) {
|
||||
return {
|
||||
authenticated: true,
|
||||
method: 'oauth',
|
||||
login: loggedInMatch[1],
|
||||
host: 'github.com',
|
||||
};
|
||||
}
|
||||
|
||||
// Check for token auth
|
||||
if (ghStatus.includes('Logged in') || ghStatus.includes('Token:')) {
|
||||
return {
|
||||
authenticated: true,
|
||||
method: 'oauth',
|
||||
host: 'github.com',
|
||||
};
|
||||
}
|
||||
} catch (ghError) {
|
||||
logger.debug(`checkAuth: gh auth status failed: ${ghError}`);
|
||||
}
|
||||
|
||||
// Try Copilot-specific auth check if gh is not available
|
||||
try {
|
||||
const result = execSync(`"${this.cliPath}" auth status`, {
|
||||
encoding: 'utf8',
|
||||
timeout: 10000,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
logger.debug(`checkAuth: copilot auth status output: ${result.substring(0, 200)}`);
|
||||
|
||||
if (result.includes('authenticated') || result.includes('logged in')) {
|
||||
return {
|
||||
authenticated: true,
|
||||
method: 'cli',
|
||||
};
|
||||
}
|
||||
} catch (copilotError) {
|
||||
logger.debug(`checkAuth: copilot auth status failed: ${copilotError}`);
|
||||
}
|
||||
|
||||
// Check for GITHUB_TOKEN environment variable
|
||||
if (process.env.GITHUB_TOKEN) {
|
||||
logger.debug('checkAuth: Found GITHUB_TOKEN environment variable');
|
||||
return {
|
||||
authenticated: true,
|
||||
method: 'oauth',
|
||||
statusMessage: 'Using GITHUB_TOKEN environment variable',
|
||||
};
|
||||
}
|
||||
|
||||
// Check for gh config file
|
||||
const ghConfigPath = path.join(os.homedir(), '.config', 'gh', 'hosts.yml');
|
||||
try {
|
||||
await fs.access(ghConfigPath);
|
||||
const content = await fs.readFile(ghConfigPath, 'utf8');
|
||||
if (content.includes('github.com') && content.includes('oauth_token')) {
|
||||
logger.debug('checkAuth: Found gh config with oauth_token');
|
||||
return {
|
||||
authenticated: true,
|
||||
method: 'oauth',
|
||||
host: 'github.com',
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
logger.debug('checkAuth: No gh config found');
|
||||
}
|
||||
|
||||
// No credentials found
|
||||
logger.debug('checkAuth: No valid credentials found');
|
||||
return {
|
||||
authenticated: false,
|
||||
method: 'none',
|
||||
error:
|
||||
'No authentication configured. Run "gh auth login" or install GitHub Copilot extension.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch available models from the CLI at runtime
|
||||
*/
|
||||
async fetchRuntimeModels(): Promise<CopilotRuntimeModel[]> {
|
||||
this.ensureCliDetected();
|
||||
if (!this.cliPath) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to list models using the CLI
|
||||
const result = execSync(`"${this.cliPath}" models list --format json`, {
|
||||
encoding: 'utf8',
|
||||
timeout: 15000,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
const models = JSON.parse(result) as CopilotRuntimeModel[];
|
||||
this.runtimeModels = models;
|
||||
logger.debug(`Fetched ${models.length} runtime models from Copilot CLI`);
|
||||
return models;
|
||||
} catch (error) {
|
||||
// Clear cache on failure to avoid returning stale data
|
||||
this.runtimeModels = null;
|
||||
logger.debug(`Failed to fetch runtime models: ${error}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
return {
|
||||
installed,
|
||||
version: version || undefined,
|
||||
path: this.cliPath || undefined,
|
||||
method: 'cli',
|
||||
authenticated: auth.authenticated,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the detected CLI path (public accessor for status endpoints)
|
||||
*/
|
||||
getCliPath(): string | null {
|
||||
this.ensureCliDetected();
|
||||
return this.cliPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available Copilot models
|
||||
*
|
||||
* Returns both static model definitions and runtime-discovered models
|
||||
*/
|
||||
getAvailableModels(): ModelDefinition[] {
|
||||
// Start with static model definitions - explicitly typed to allow runtime models
|
||||
const staticModels: ModelDefinition[] = Object.entries(COPILOT_MODEL_MAP).map(
|
||||
([id, config]) => ({
|
||||
id, // Full model ID with copilot- prefix
|
||||
name: config.label,
|
||||
modelString: id.replace('copilot-', ''), // Bare model for CLI
|
||||
provider: 'copilot',
|
||||
description: config.description,
|
||||
supportsTools: config.supportsTools,
|
||||
supportsVision: config.supportsVision,
|
||||
contextWindow: config.contextWindow,
|
||||
})
|
||||
);
|
||||
|
||||
// Add runtime models if available (discovered via CLI)
|
||||
if (this.runtimeModels) {
|
||||
for (const runtimeModel of this.runtimeModels) {
|
||||
// Skip if already in static list
|
||||
const staticId = `copilot-${runtimeModel.id}`;
|
||||
if (staticModels.some((m) => m.id === staticId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
staticModels.push({
|
||||
id: staticId,
|
||||
name: runtimeModel.name || runtimeModel.id,
|
||||
modelString: runtimeModel.id,
|
||||
provider: 'copilot',
|
||||
description: `Dynamic model: ${runtimeModel.name || runtimeModel.id}`,
|
||||
supportsTools: true,
|
||||
supportsVision: runtimeModel.capabilities?.supportsVision ?? false,
|
||||
contextWindow: runtimeModel.capabilities?.maxInputTokens,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return staticModels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a feature is supported
|
||||
*
|
||||
* Note: Vision is NOT currently supported - the SDK doesn't handle image inputs yet.
|
||||
* This may change in future versions of the Copilot SDK.
|
||||
*/
|
||||
supportsFeature(feature: string): boolean {
|
||||
const supported = ['tools', 'text', 'streaming'];
|
||||
return supported.includes(feature);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if runtime models have been cached
|
||||
*/
|
||||
hasCachedModels(): boolean {
|
||||
return this.runtimeModels !== null && this.runtimeModels.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the runtime model cache
|
||||
*/
|
||||
clearModelCache(): void {
|
||||
this.runtimeModels = null;
|
||||
logger.debug('Cleared Copilot model cache');
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh models from CLI and return all available models
|
||||
*/
|
||||
async refreshModels(): Promise<ModelDefinition[]> {
|
||||
logger.debug('Refreshing Copilot models from CLI');
|
||||
await this.fetchRuntimeModels();
|
||||
return this.getAvailableModels();
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@ export class CursorConfigManager {
|
||||
|
||||
// Return default config with all available models
|
||||
return {
|
||||
defaultModel: 'auto',
|
||||
defaultModel: 'cursor-auto',
|
||||
models: getAllCursorModelIds(),
|
||||
};
|
||||
}
|
||||
@@ -77,7 +77,7 @@ export class CursorConfigManager {
|
||||
* Get the default model
|
||||
*/
|
||||
getDefaultModel(): CursorModelId {
|
||||
return this.config.defaultModel || 'auto';
|
||||
return this.config.defaultModel || 'cursor-auto';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,7 +93,7 @@ export class CursorConfigManager {
|
||||
* Get enabled models
|
||||
*/
|
||||
getEnabledModels(): CursorModelId[] {
|
||||
return this.config.models || ['auto'];
|
||||
return this.config.models || ['cursor-auto'];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -174,7 +174,7 @@ export class CursorConfigManager {
|
||||
*/
|
||||
reset(): void {
|
||||
this.config = {
|
||||
defaultModel: 'auto',
|
||||
defaultModel: 'cursor-auto',
|
||||
models: getAllCursorModelIds(),
|
||||
};
|
||||
this.saveConfig();
|
||||
|
||||
@@ -337,10 +337,11 @@ export class CursorProvider extends CliProvider {
|
||||
'--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) {
|
||||
// In read-only mode, use --mode ask for Q&A style (no tools)
|
||||
// Otherwise, add --force to allow file edits
|
||||
if (options.readOnly) {
|
||||
cliArgs.push('--mode', 'ask');
|
||||
} else {
|
||||
cliArgs.push('--force');
|
||||
}
|
||||
|
||||
@@ -672,10 +673,13 @@ export class CursorProvider extends CliProvider {
|
||||
);
|
||||
}
|
||||
|
||||
// Extract prompt text to pass via stdin (avoids shell escaping issues)
|
||||
const promptText = this.extractPromptText(options);
|
||||
// Embed system prompt into user prompt (Cursor CLI doesn't support separate system messages)
|
||||
const effectiveOptions = this.embedSystemPromptIntoPrompt(options);
|
||||
|
||||
const cliArgs = this.buildCliArgs(options);
|
||||
// Extract prompt text to pass via stdin (avoids shell escaping issues)
|
||||
const promptText = this.extractPromptText(effectiveOptions);
|
||||
|
||||
const cliArgs = this.buildCliArgs(effectiveOptions);
|
||||
const subprocessOptions = this.buildSubprocessOptions(options, cliArgs);
|
||||
|
||||
// Pass prompt via stdin to avoid shell interpretation of special characters
|
||||
|
||||
810
apps/server/src/providers/gemini-provider.ts
Normal file
810
apps/server/src/providers/gemini-provider.ts
Normal file
@@ -0,0 +1,810 @@
|
||||
/**
|
||||
* Gemini Provider - Executes queries using the Gemini CLI
|
||||
*
|
||||
* Extends CliProvider with Gemini-specific:
|
||||
* - Event normalization for Gemini's JSONL streaming format
|
||||
* - Google account and API key authentication support
|
||||
* - Thinking level configuration
|
||||
*
|
||||
* Based on https://github.com/google-gemini/gemini-cli
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { CliProvider, type CliSpawnConfig, type CliErrorInfo } from './cli-provider.js';
|
||||
import type {
|
||||
ProviderConfig,
|
||||
ExecuteOptions,
|
||||
ProviderMessage,
|
||||
InstallationStatus,
|
||||
ModelDefinition,
|
||||
ContentBlock,
|
||||
} from './types.js';
|
||||
import { validateBareModelId } from '@automaker/types';
|
||||
import { GEMINI_MODEL_MAP, type GeminiAuthStatus } from '@automaker/types';
|
||||
import { createLogger, isAbortError } from '@automaker/utils';
|
||||
import { spawnJSONLProcess } from '@automaker/platform';
|
||||
import { normalizeTodos } from './tool-normalization.js';
|
||||
|
||||
// Create logger for this module
|
||||
const logger = createLogger('GeminiProvider');
|
||||
|
||||
// =============================================================================
|
||||
// Gemini Stream Event Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Base event structure from Gemini CLI --output-format stream-json
|
||||
*
|
||||
* Actual CLI output format:
|
||||
* {"type":"init","timestamp":"...","session_id":"...","model":"..."}
|
||||
* {"type":"message","timestamp":"...","role":"user","content":"..."}
|
||||
* {"type":"message","timestamp":"...","role":"assistant","content":"...","delta":true}
|
||||
* {"type":"tool_use","timestamp":"...","tool_name":"...","tool_id":"...","parameters":{...}}
|
||||
* {"type":"tool_result","timestamp":"...","tool_id":"...","status":"success","output":"..."}
|
||||
* {"type":"result","timestamp":"...","status":"success","stats":{...}}
|
||||
*/
|
||||
interface GeminiStreamEvent {
|
||||
type: 'init' | 'message' | 'tool_use' | 'tool_result' | 'result' | 'error';
|
||||
timestamp?: string;
|
||||
session_id?: string;
|
||||
}
|
||||
|
||||
interface GeminiInitEvent extends GeminiStreamEvent {
|
||||
type: 'init';
|
||||
session_id: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
interface GeminiMessageEvent extends GeminiStreamEvent {
|
||||
type: 'message';
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
delta?: boolean;
|
||||
session_id?: string;
|
||||
}
|
||||
|
||||
interface GeminiToolUseEvent extends GeminiStreamEvent {
|
||||
type: 'tool_use';
|
||||
tool_id: string;
|
||||
tool_name: string;
|
||||
parameters: Record<string, unknown>;
|
||||
session_id?: string;
|
||||
}
|
||||
|
||||
interface GeminiToolResultEvent extends GeminiStreamEvent {
|
||||
type: 'tool_result';
|
||||
tool_id: string;
|
||||
status: 'success' | 'error';
|
||||
output: string;
|
||||
session_id?: string;
|
||||
}
|
||||
|
||||
interface GeminiResultEvent extends GeminiStreamEvent {
|
||||
type: 'result';
|
||||
status: 'success' | 'error';
|
||||
stats?: {
|
||||
total_tokens?: number;
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
cached?: number;
|
||||
input?: number;
|
||||
duration_ms?: number;
|
||||
tool_calls?: number;
|
||||
};
|
||||
error?: string;
|
||||
session_id?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Error Codes
|
||||
// =============================================================================
|
||||
|
||||
export enum GeminiErrorCode {
|
||||
NOT_INSTALLED = 'GEMINI_NOT_INSTALLED',
|
||||
NOT_AUTHENTICATED = 'GEMINI_NOT_AUTHENTICATED',
|
||||
RATE_LIMITED = 'GEMINI_RATE_LIMITED',
|
||||
MODEL_UNAVAILABLE = 'GEMINI_MODEL_UNAVAILABLE',
|
||||
NETWORK_ERROR = 'GEMINI_NETWORK_ERROR',
|
||||
PROCESS_CRASHED = 'GEMINI_PROCESS_CRASHED',
|
||||
TIMEOUT = 'GEMINI_TIMEOUT',
|
||||
UNKNOWN = 'GEMINI_UNKNOWN_ERROR',
|
||||
}
|
||||
|
||||
export interface GeminiError extends Error {
|
||||
code: GeminiErrorCode;
|
||||
recoverable: boolean;
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tool Name Normalization
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Gemini CLI tool name to standard tool name mapping
|
||||
* This allows the UI to properly categorize and display Gemini tool calls
|
||||
*/
|
||||
const GEMINI_TOOL_NAME_MAP: Record<string, string> = {
|
||||
write_todos: 'TodoWrite',
|
||||
read_file: 'Read',
|
||||
read_many_files: 'Read',
|
||||
replace: 'Edit',
|
||||
write_file: 'Write',
|
||||
run_shell_command: 'Bash',
|
||||
search_file_content: 'Grep',
|
||||
glob: 'Glob',
|
||||
list_directory: 'Ls',
|
||||
web_fetch: 'WebFetch',
|
||||
google_web_search: 'WebSearch',
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize Gemini tool names to standard tool names
|
||||
*/
|
||||
function normalizeGeminiToolName(geminiToolName: string): string {
|
||||
return GEMINI_TOOL_NAME_MAP[geminiToolName] || geminiToolName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize Gemini tool input parameters to standard format
|
||||
*
|
||||
* Uses shared normalizeTodos utility for consistent todo normalization.
|
||||
*
|
||||
* Gemini `write_todos` format:
|
||||
* {"todos": [{"description": "Task text", "status": "pending|in_progress|completed|cancelled"}]}
|
||||
*
|
||||
* Claude `TodoWrite` format:
|
||||
* {"todos": [{"content": "Task text", "status": "pending|in_progress|completed", "activeForm": "..."}]}
|
||||
*/
|
||||
function normalizeGeminiToolInput(
|
||||
toolName: string,
|
||||
input: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
// Normalize write_todos using shared utility
|
||||
if (toolName === 'write_todos' && Array.isArray(input.todos)) {
|
||||
return { todos: normalizeTodos(input.todos) };
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
/**
|
||||
* GeminiProvider - Integrates Gemini CLI as an AI provider
|
||||
*
|
||||
* Features:
|
||||
* - Google account OAuth login support
|
||||
* - API key authentication (GEMINI_API_KEY)
|
||||
* - Vertex AI support
|
||||
* - Thinking level configuration
|
||||
* - Streaming JSON output
|
||||
*/
|
||||
export class GeminiProvider extends CliProvider {
|
||||
constructor(config: ProviderConfig = {}) {
|
||||
super(config);
|
||||
// Trigger CLI detection on construction
|
||||
this.ensureCliDetected();
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// CliProvider Abstract Method Implementations
|
||||
// ==========================================================================
|
||||
|
||||
getName(): string {
|
||||
return 'gemini';
|
||||
}
|
||||
|
||||
getCliName(): string {
|
||||
return 'gemini';
|
||||
}
|
||||
|
||||
getSpawnConfig(): CliSpawnConfig {
|
||||
return {
|
||||
windowsStrategy: 'npx', // Gemini CLI can be run via npx
|
||||
npxPackage: '@google/gemini-cli', // Official Google Gemini CLI package
|
||||
commonPaths: {
|
||||
linux: [
|
||||
path.join(os.homedir(), '.local/bin/gemini'),
|
||||
'/usr/local/bin/gemini',
|
||||
path.join(os.homedir(), '.npm-global/bin/gemini'),
|
||||
],
|
||||
darwin: [
|
||||
path.join(os.homedir(), '.local/bin/gemini'),
|
||||
'/usr/local/bin/gemini',
|
||||
'/opt/homebrew/bin/gemini',
|
||||
path.join(os.homedir(), '.npm-global/bin/gemini'),
|
||||
],
|
||||
win32: [
|
||||
path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'gemini.cmd'),
|
||||
path.join(os.homedir(), '.npm-global', 'gemini.cmd'),
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract prompt text from ExecuteOptions
|
||||
*/
|
||||
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[] {
|
||||
// Model comes in stripped of provider prefix (e.g., '2.5-flash' from 'gemini-2.5-flash')
|
||||
// We need to add 'gemini-' back since it's part of the actual CLI model name
|
||||
const bareModel = options.model || '2.5-flash';
|
||||
const cliArgs: string[] = [];
|
||||
|
||||
// Streaming JSON output format for real-time updates
|
||||
cliArgs.push('--output-format', 'stream-json');
|
||||
|
||||
// Model selection - Gemini CLI expects full model names like "gemini-2.5-flash"
|
||||
// Unlike Cursor CLI where 'cursor-' is just a routing prefix, for Gemini CLI
|
||||
// the 'gemini-' is part of the actual model name Google expects
|
||||
if (bareModel && bareModel !== 'auto') {
|
||||
// Add gemini- prefix if not already present (handles edge cases)
|
||||
const cliModel = bareModel.startsWith('gemini-') ? bareModel : `gemini-${bareModel}`;
|
||||
cliArgs.push('--model', cliModel);
|
||||
}
|
||||
|
||||
// Disable sandbox mode for faster execution (sandbox adds overhead)
|
||||
cliArgs.push('--sandbox', 'false');
|
||||
|
||||
// YOLO mode for automatic approval (required for non-interactive use)
|
||||
// Use explicit approval-mode for clearer semantics
|
||||
cliArgs.push('--approval-mode', 'yolo');
|
||||
|
||||
// Explicitly include the working directory in allowed workspace directories
|
||||
// This ensures Gemini CLI allows file operations in the project directory,
|
||||
// even if it has a different workspace cached from a previous session
|
||||
if (options.cwd) {
|
||||
cliArgs.push('--include-directories', options.cwd);
|
||||
}
|
||||
|
||||
// Note: Gemini CLI doesn't have a --thinking-level flag.
|
||||
// Thinking capabilities are determined by the model selection (e.g., gemini-2.5-pro).
|
||||
// The model handles thinking internally based on the task complexity.
|
||||
|
||||
// The prompt will be passed as the last positional argument
|
||||
// We'll append it in executeQuery after extracting the text
|
||||
|
||||
return cliArgs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Gemini event to AutoMaker ProviderMessage format
|
||||
*/
|
||||
normalizeEvent(event: unknown): ProviderMessage | null {
|
||||
const geminiEvent = event as GeminiStreamEvent;
|
||||
|
||||
switch (geminiEvent.type) {
|
||||
case 'init': {
|
||||
// Init event - capture session but don't yield a message
|
||||
const initEvent = geminiEvent as GeminiInitEvent;
|
||||
logger.debug(
|
||||
`Gemini init event: session=${initEvent.session_id}, model=${initEvent.model}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'message': {
|
||||
const messageEvent = geminiEvent as GeminiMessageEvent;
|
||||
|
||||
// Skip user messages - already handled by caller
|
||||
if (messageEvent.role === 'user') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle assistant messages
|
||||
if (messageEvent.role === 'assistant') {
|
||||
return {
|
||||
type: 'assistant',
|
||||
session_id: messageEvent.session_id,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: messageEvent.content }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'tool_use': {
|
||||
const toolEvent = geminiEvent as GeminiToolUseEvent;
|
||||
const normalizedName = normalizeGeminiToolName(toolEvent.tool_name);
|
||||
const normalizedInput = normalizeGeminiToolInput(
|
||||
toolEvent.tool_name,
|
||||
toolEvent.parameters as Record<string, unknown>
|
||||
);
|
||||
|
||||
return {
|
||||
type: 'assistant',
|
||||
session_id: toolEvent.session_id,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
name: normalizedName,
|
||||
tool_use_id: toolEvent.tool_id,
|
||||
input: normalizedInput,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'tool_result': {
|
||||
const toolResultEvent = geminiEvent as GeminiToolResultEvent;
|
||||
// If tool result is an error, prefix with error indicator
|
||||
const content =
|
||||
toolResultEvent.status === 'error'
|
||||
? `[ERROR] ${toolResultEvent.output}`
|
||||
: toolResultEvent.output;
|
||||
return {
|
||||
type: 'assistant',
|
||||
session_id: toolResultEvent.session_id,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: toolResultEvent.tool_id,
|
||||
content,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'result': {
|
||||
const resultEvent = geminiEvent as GeminiResultEvent;
|
||||
|
||||
if (resultEvent.status === 'error') {
|
||||
return {
|
||||
type: 'error',
|
||||
session_id: resultEvent.session_id,
|
||||
error: resultEvent.error || 'Unknown error',
|
||||
};
|
||||
}
|
||||
|
||||
// Success result - include stats for logging
|
||||
logger.debug(
|
||||
`Gemini result: status=${resultEvent.status}, tokens=${resultEvent.stats?.total_tokens}`
|
||||
);
|
||||
return {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
session_id: resultEvent.session_id,
|
||||
};
|
||||
}
|
||||
|
||||
case 'error': {
|
||||
const errorEvent = geminiEvent as GeminiResultEvent;
|
||||
return {
|
||||
type: 'error',
|
||||
session_id: errorEvent.session_id,
|
||||
error: errorEvent.error || 'Unknown error',
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
logger.debug(`Unknown Gemini event type: ${geminiEvent.type}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// CliProvider Overrides
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Override error mapping for Gemini-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') ||
|
||||
lower.includes('login required') ||
|
||||
lower.includes('error authenticating') ||
|
||||
lower.includes('loadcodeassist') ||
|
||||
(lower.includes('econnrefused') && lower.includes('8888'))
|
||||
) {
|
||||
return {
|
||||
code: GeminiErrorCode.NOT_AUTHENTICATED,
|
||||
message: 'Gemini CLI is not authenticated',
|
||||
recoverable: true,
|
||||
suggestion:
|
||||
'Run "gemini" interactively to log in, or set GEMINI_API_KEY environment variable',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
lower.includes('rate limit') ||
|
||||
lower.includes('too many requests') ||
|
||||
lower.includes('429') ||
|
||||
lower.includes('quota exceeded')
|
||||
) {
|
||||
return {
|
||||
code: GeminiErrorCode.RATE_LIMITED,
|
||||
message: 'Gemini API rate limit exceeded',
|
||||
recoverable: true,
|
||||
suggestion: 'Wait a few minutes and try again. Free tier: 60 req/min, 1000 req/day',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
lower.includes('model not available') ||
|
||||
lower.includes('invalid model') ||
|
||||
lower.includes('unknown model') ||
|
||||
lower.includes('modelnotfounderror') ||
|
||||
lower.includes('model not found') ||
|
||||
(lower.includes('not found') && lower.includes('404'))
|
||||
) {
|
||||
return {
|
||||
code: GeminiErrorCode.MODEL_UNAVAILABLE,
|
||||
message: 'Requested model is not available',
|
||||
recoverable: true,
|
||||
suggestion: 'Try using "gemini-2.5-flash" or select a different model',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
lower.includes('network') ||
|
||||
lower.includes('connection') ||
|
||||
lower.includes('econnrefused') ||
|
||||
lower.includes('timeout')
|
||||
) {
|
||||
return {
|
||||
code: GeminiErrorCode.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: GeminiErrorCode.PROCESS_CRASHED,
|
||||
message: 'Gemini CLI process was terminated',
|
||||
recoverable: true,
|
||||
suggestion: 'The process may have run out of memory. Try a simpler task.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
code: GeminiErrorCode.UNKNOWN,
|
||||
message: stderr || `Gemini CLI exited with code ${exitCode}`,
|
||||
recoverable: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Override install instructions for Gemini-specific guidance
|
||||
*/
|
||||
protected getInstallInstructions(): string {
|
||||
return 'Install with: npm install -g @google/gemini-cli (or visit https://github.com/google-gemini/gemini-cli)';
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a prompt using Gemini CLI with streaming
|
||||
*/
|
||||
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
|
||||
this.ensureCliDetected();
|
||||
|
||||
// Validate that model doesn't have a provider prefix
|
||||
validateBareModelId(options.model, 'GeminiProvider');
|
||||
|
||||
if (!this.cliPath) {
|
||||
throw this.createError(
|
||||
GeminiErrorCode.NOT_INSTALLED,
|
||||
'Gemini CLI is not installed',
|
||||
true,
|
||||
this.getInstallInstructions()
|
||||
);
|
||||
}
|
||||
|
||||
// Extract prompt text to pass as positional argument
|
||||
const promptText = this.extractPromptText(options);
|
||||
|
||||
// Build CLI args and append the prompt as the last positional argument
|
||||
const cliArgs = this.buildCliArgs(options);
|
||||
cliArgs.push(promptText); // Gemini CLI uses positional args for the prompt
|
||||
|
||||
const subprocessOptions = this.buildSubprocessOptions(options, cliArgs);
|
||||
|
||||
let sessionId: string | undefined;
|
||||
|
||||
logger.debug(`GeminiProvider.executeQuery called with model: "${options.model}"`);
|
||||
|
||||
try {
|
||||
for await (const rawEvent of spawnJSONLProcess(subprocessOptions)) {
|
||||
const event = rawEvent as GeminiStreamEvent;
|
||||
|
||||
// Capture session ID from init event
|
||||
if (event.type === 'init') {
|
||||
const initEvent = event as GeminiInitEvent;
|
||||
sessionId = initEvent.session_id;
|
||||
logger.debug(`Session started: ${sessionId}, model: ${initEvent.model}`);
|
||||
}
|
||||
|
||||
// Normalize and yield the event
|
||||
const normalized = this.normalizeEvent(event);
|
||||
if (normalized) {
|
||||
if (!normalized.session_id && sessionId) {
|
||||
normalized.session_id = sessionId;
|
||||
}
|
||||
yield normalized;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
logger.debug('Query aborted');
|
||||
return;
|
||||
}
|
||||
|
||||
// Map CLI errors to GeminiError
|
||||
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 GeminiErrorCode,
|
||||
errorInfo.message,
|
||||
errorInfo.recoverable,
|
||||
errorInfo.suggestion
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Gemini-Specific Methods
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Create a GeminiError with details
|
||||
*/
|
||||
private createError(
|
||||
code: GeminiErrorCode,
|
||||
message: string,
|
||||
recoverable: boolean = false,
|
||||
suggestion?: string
|
||||
): GeminiError {
|
||||
const error = new Error(message) as GeminiError;
|
||||
error.code = code;
|
||||
error.recoverable = recoverable;
|
||||
error.suggestion = suggestion;
|
||||
error.name = 'GeminiError';
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Gemini CLI version
|
||||
*/
|
||||
async getVersion(): Promise<string | null> {
|
||||
this.ensureCliDetected();
|
||||
if (!this.cliPath) return null;
|
||||
|
||||
try {
|
||||
const result = execSync(`"${this.cliPath}" --version`, {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
stdio: 'pipe',
|
||||
}).trim();
|
||||
return result;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check authentication status
|
||||
*
|
||||
* Uses a fast credential check approach:
|
||||
* 1. Check for GEMINI_API_KEY environment variable
|
||||
* 2. Check for Google Cloud credentials
|
||||
* 3. Check for Gemini settings file with stored credentials
|
||||
* 4. Quick CLI auth test with --help (fast, doesn't make API calls)
|
||||
*/
|
||||
async checkAuth(): Promise<GeminiAuthStatus> {
|
||||
this.ensureCliDetected();
|
||||
if (!this.cliPath) {
|
||||
logger.debug('checkAuth: CLI not found');
|
||||
return { authenticated: false, method: 'none' };
|
||||
}
|
||||
|
||||
logger.debug('checkAuth: Starting credential check');
|
||||
|
||||
// Determine the likely auth method based on environment
|
||||
const hasApiKey = !!process.env.GEMINI_API_KEY;
|
||||
const hasEnvApiKey = hasApiKey;
|
||||
const hasVertexAi = !!(
|
||||
process.env.GOOGLE_APPLICATION_CREDENTIALS || process.env.GOOGLE_CLOUD_PROJECT
|
||||
);
|
||||
|
||||
logger.debug(`checkAuth: hasApiKey=${hasApiKey}, hasVertexAi=${hasVertexAi}`);
|
||||
|
||||
// Check for Gemini credentials file (~/.gemini/settings.json)
|
||||
const geminiConfigDir = path.join(os.homedir(), '.gemini');
|
||||
const settingsPath = path.join(geminiConfigDir, 'settings.json');
|
||||
let hasCredentialsFile = false;
|
||||
let authType: string | null = null;
|
||||
|
||||
try {
|
||||
await fs.access(settingsPath);
|
||||
logger.debug(`checkAuth: Found settings file at ${settingsPath}`);
|
||||
try {
|
||||
const content = await fs.readFile(settingsPath, 'utf8');
|
||||
const settings = JSON.parse(content);
|
||||
|
||||
// Auth config is at security.auth.selectedType (e.g., "oauth-personal", "oauth-adc", "api-key")
|
||||
const selectedType = settings?.security?.auth?.selectedType;
|
||||
if (selectedType) {
|
||||
hasCredentialsFile = true;
|
||||
authType = selectedType;
|
||||
logger.debug(`checkAuth: Settings file has auth config, selectedType=${selectedType}`);
|
||||
} else {
|
||||
logger.debug(`checkAuth: Settings file found but no auth type configured`);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.debug(`checkAuth: Failed to parse settings file: ${e}`);
|
||||
}
|
||||
} catch {
|
||||
logger.debug('checkAuth: No settings file found');
|
||||
}
|
||||
|
||||
// If we have an API key, we're authenticated
|
||||
if (hasApiKey) {
|
||||
logger.debug('checkAuth: Using API key authentication');
|
||||
return {
|
||||
authenticated: true,
|
||||
method: 'api_key',
|
||||
hasApiKey,
|
||||
hasEnvApiKey,
|
||||
hasCredentialsFile,
|
||||
};
|
||||
}
|
||||
|
||||
// If we have Vertex AI credentials, we're authenticated
|
||||
if (hasVertexAi) {
|
||||
logger.debug('checkAuth: Using Vertex AI authentication');
|
||||
return {
|
||||
authenticated: true,
|
||||
method: 'vertex_ai',
|
||||
hasApiKey,
|
||||
hasEnvApiKey,
|
||||
hasCredentialsFile,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if settings file indicates configured authentication
|
||||
if (hasCredentialsFile && authType) {
|
||||
// OAuth types: "oauth-personal", "oauth-adc"
|
||||
// API key type: "api-key"
|
||||
// Code assist: "code-assist" (requires IDE integration)
|
||||
if (authType.startsWith('oauth')) {
|
||||
logger.debug(`checkAuth: OAuth authentication configured (${authType})`);
|
||||
return {
|
||||
authenticated: true,
|
||||
method: 'google_login',
|
||||
hasApiKey,
|
||||
hasEnvApiKey,
|
||||
hasCredentialsFile,
|
||||
};
|
||||
}
|
||||
|
||||
if (authType === 'api-key') {
|
||||
logger.debug('checkAuth: API key authentication configured in settings');
|
||||
return {
|
||||
authenticated: true,
|
||||
method: 'api_key',
|
||||
hasApiKey,
|
||||
hasEnvApiKey,
|
||||
hasCredentialsFile,
|
||||
};
|
||||
}
|
||||
|
||||
if (authType === 'code-assist' || authType === 'codeassist') {
|
||||
logger.debug('checkAuth: Code Assist auth configured but requires local server');
|
||||
return {
|
||||
authenticated: false,
|
||||
method: 'google_login',
|
||||
hasApiKey,
|
||||
hasEnvApiKey,
|
||||
hasCredentialsFile,
|
||||
error:
|
||||
'Code Assist authentication requires IDE integration. Please use "gemini" CLI to log in with a different method, or set GEMINI_API_KEY.',
|
||||
};
|
||||
}
|
||||
|
||||
// Unknown auth type but something is configured
|
||||
logger.debug(`checkAuth: Unknown auth type configured: ${authType}`);
|
||||
return {
|
||||
authenticated: true,
|
||||
method: 'google_login',
|
||||
hasApiKey,
|
||||
hasEnvApiKey,
|
||||
hasCredentialsFile,
|
||||
};
|
||||
}
|
||||
|
||||
// No credentials found
|
||||
logger.debug('checkAuth: No valid credentials found');
|
||||
return {
|
||||
authenticated: false,
|
||||
method: 'none',
|
||||
hasApiKey,
|
||||
hasEnvApiKey,
|
||||
hasCredentialsFile,
|
||||
error:
|
||||
'No authentication configured. Run "gemini" interactively to log in, or set GEMINI_API_KEY.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
return {
|
||||
installed,
|
||||
version: version || undefined,
|
||||
path: this.cliPath || undefined,
|
||||
method: 'cli',
|
||||
hasApiKey: !!process.env.GEMINI_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 Gemini models
|
||||
*/
|
||||
getAvailableModels(): ModelDefinition[] {
|
||||
return Object.entries(GEMINI_MODEL_MAP).map(([id, config]) => ({
|
||||
id, // Full model ID with gemini- prefix (e.g., 'gemini-2.5-flash')
|
||||
name: config.label,
|
||||
modelString: id, // Same as id - CLI uses the full model name
|
||||
provider: 'gemini',
|
||||
description: config.description,
|
||||
supportsTools: true,
|
||||
supportsVision: config.supportsVision,
|
||||
contextWindow: config.contextWindow,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a feature is supported
|
||||
*/
|
||||
supportsFeature(feature: string): boolean {
|
||||
const supported = ['tools', 'text', 'streaming', 'vision', 'thinking'];
|
||||
return supported.includes(feature);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,16 @@ export type {
|
||||
ProviderMessage,
|
||||
InstallationStatus,
|
||||
ModelDefinition,
|
||||
AgentDefinition,
|
||||
ReasoningEffort,
|
||||
SystemPromptPreset,
|
||||
ConversationMessage,
|
||||
ContentBlock,
|
||||
ValidationResult,
|
||||
McpServerConfig,
|
||||
McpStdioServerConfig,
|
||||
McpSSEServerConfig,
|
||||
McpHttpServerConfig,
|
||||
} from './types.js';
|
||||
|
||||
// Claude provider
|
||||
@@ -28,6 +38,12 @@ export { CursorConfigManager } from './cursor-config-manager.js';
|
||||
// OpenCode provider
|
||||
export { OpencodeProvider } from './opencode-provider.js';
|
||||
|
||||
// Gemini provider
|
||||
export { GeminiProvider, GeminiErrorCode } from './gemini-provider.js';
|
||||
|
||||
// Copilot provider (GitHub Copilot SDK)
|
||||
export { CopilotProvider, CopilotErrorCode } from './copilot-provider.js';
|
||||
|
||||
// Provider factory
|
||||
export { ProviderFactory } from './provider-factory.js';
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ import type {
|
||||
InstallationStatus,
|
||||
ContentBlock,
|
||||
} from '@automaker/types';
|
||||
import { stripProviderPrefix } from '@automaker/types';
|
||||
import { type SubprocessOptions, getOpenCodeAuthIndicators } from '@automaker/platform';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
@@ -328,10 +327,18 @@ export class OpencodeProvider extends CliProvider {
|
||||
args.push('--format', 'json');
|
||||
|
||||
// Handle model selection
|
||||
// Strip 'opencode-' prefix if present, OpenCode uses format like 'anthropic/claude-sonnet-4-5'
|
||||
// Convert canonical prefix format (opencode-xxx) to CLI slash format (opencode/xxx)
|
||||
// OpenCode CLI expects provider/model format (e.g., 'opencode/big-model')
|
||||
if (options.model) {
|
||||
const model = stripProviderPrefix(options.model);
|
||||
args.push('--model', model);
|
||||
// Strip opencode- prefix if present, then ensure slash format
|
||||
const model = options.model.startsWith('opencode-')
|
||||
? options.model.slice('opencode-'.length)
|
||||
: options.model;
|
||||
|
||||
// If model has slash, it's already provider/model format; otherwise prepend opencode/
|
||||
const cliModel = model.includes('/') ? model : `opencode/${model}`;
|
||||
|
||||
args.push('--model', cliModel);
|
||||
}
|
||||
|
||||
// Note: OpenCode reads from stdin automatically when input is piped
|
||||
@@ -1035,7 +1042,7 @@ export class OpencodeProvider extends CliProvider {
|
||||
'lm studio': 'lmstudio',
|
||||
lmstudio: 'lmstudio',
|
||||
opencode: 'opencode',
|
||||
'z.ai coding plan': 'z-ai',
|
||||
'z.ai coding plan': 'zai-coding-plan',
|
||||
'z.ai': 'z-ai',
|
||||
};
|
||||
|
||||
|
||||
@@ -7,7 +7,14 @@
|
||||
|
||||
import { BaseProvider } from './base-provider.js';
|
||||
import type { InstallationStatus, ModelDefinition } from './types.js';
|
||||
import { isCursorModel, isCodexModel, isOpencodeModel, type ModelProvider } from '@automaker/types';
|
||||
import {
|
||||
isCursorModel,
|
||||
isCodexModel,
|
||||
isOpencodeModel,
|
||||
isGeminiModel,
|
||||
isCopilotModel,
|
||||
type ModelProvider,
|
||||
} from '@automaker/types';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
@@ -16,6 +23,8 @@ const DISCONNECTED_MARKERS: Record<string, string> = {
|
||||
codex: '.codex-disconnected',
|
||||
cursor: '.cursor-disconnected',
|
||||
opencode: '.opencode-disconnected',
|
||||
gemini: '.gemini-disconnected',
|
||||
copilot: '.copilot-disconnected',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -239,8 +248,8 @@ export class ProviderFactory {
|
||||
model.modelString === modelId ||
|
||||
model.id.endsWith(`-${modelId}`) ||
|
||||
model.modelString.endsWith(`-${modelId}`) ||
|
||||
model.modelString === modelId.replace(/^(claude|cursor|codex)-/, '') ||
|
||||
model.modelString === modelId.replace(/-(claude|cursor|codex)$/, '')
|
||||
model.modelString === modelId.replace(/^(claude|cursor|codex|gemini)-/, '') ||
|
||||
model.modelString === modelId.replace(/-(claude|cursor|codex|gemini)$/, '')
|
||||
) {
|
||||
return model.supportsVision ?? true;
|
||||
}
|
||||
@@ -267,6 +276,8 @@ import { ClaudeProvider } from './claude-provider.js';
|
||||
import { CursorProvider } from './cursor-provider.js';
|
||||
import { CodexProvider } from './codex-provider.js';
|
||||
import { OpencodeProvider } from './opencode-provider.js';
|
||||
import { GeminiProvider } from './gemini-provider.js';
|
||||
import { CopilotProvider } from './copilot-provider.js';
|
||||
|
||||
// Register Claude provider
|
||||
registerProvider('claude', {
|
||||
@@ -301,3 +312,19 @@ registerProvider('opencode', {
|
||||
canHandleModel: (model: string) => isOpencodeModel(model),
|
||||
priority: 3, // Between codex (5) and claude (0)
|
||||
});
|
||||
|
||||
// Register Gemini provider
|
||||
registerProvider('gemini', {
|
||||
factory: () => new GeminiProvider(),
|
||||
aliases: ['google'],
|
||||
canHandleModel: (model: string) => isGeminiModel(model),
|
||||
priority: 4, // Between opencode (3) and codex (5)
|
||||
});
|
||||
|
||||
// Register Copilot provider (GitHub Copilot SDK)
|
||||
registerProvider('copilot', {
|
||||
factory: () => new CopilotProvider(),
|
||||
aliases: ['github-copilot', 'github'],
|
||||
canHandleModel: (model: string) => isCopilotModel(model),
|
||||
priority: 6, // High priority - check before Codex since both can handle GPT models
|
||||
});
|
||||
|
||||
@@ -20,6 +20,9 @@ import type {
|
||||
ContentBlock,
|
||||
ThinkingLevel,
|
||||
ReasoningEffort,
|
||||
ClaudeApiProfile,
|
||||
ClaudeCompatibleProvider,
|
||||
Credentials,
|
||||
} from '@automaker/types';
|
||||
import { stripProviderPrefix } from '@automaker/types';
|
||||
|
||||
@@ -54,6 +57,18 @@ export interface SimpleQueryOptions {
|
||||
readOnly?: boolean;
|
||||
/** Setting sources for CLAUDE.md loading */
|
||||
settingSources?: Array<'user' | 'project' | 'local'>;
|
||||
/**
|
||||
* Active Claude API profile for alternative endpoint configuration
|
||||
* @deprecated Use claudeCompatibleProvider instead
|
||||
*/
|
||||
claudeApiProfile?: ClaudeApiProfile;
|
||||
/**
|
||||
* Claude-compatible provider for alternative endpoint configuration.
|
||||
* Takes precedence over claudeApiProfile if both are set.
|
||||
*/
|
||||
claudeCompatibleProvider?: ClaudeCompatibleProvider;
|
||||
/** Credentials for resolving 'credentials' apiKeySource in Claude API profiles/providers */
|
||||
credentials?: Credentials;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,6 +140,9 @@ export async function simpleQuery(options: SimpleQueryOptions): Promise<SimpleQu
|
||||
reasoningEffort: options.reasoningEffort,
|
||||
readOnly: options.readOnly,
|
||||
settingSources: options.settingSources,
|
||||
claudeApiProfile: options.claudeApiProfile, // Legacy: Pass active Claude API profile for alternative endpoint configuration
|
||||
claudeCompatibleProvider: options.claudeCompatibleProvider, // New: Pass Claude-compatible provider (takes precedence)
|
||||
credentials: options.credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
};
|
||||
|
||||
for await (const msg of provider.executeQuery(providerOptions)) {
|
||||
@@ -207,6 +225,9 @@ export async function streamingQuery(options: StreamingQueryOptions): Promise<Si
|
||||
reasoningEffort: options.reasoningEffort,
|
||||
readOnly: options.readOnly,
|
||||
settingSources: options.settingSources,
|
||||
claudeApiProfile: options.claudeApiProfile, // Legacy: Pass active Claude API profile for alternative endpoint configuration
|
||||
claudeCompatibleProvider: options.claudeCompatibleProvider, // New: Pass Claude-compatible provider (takes precedence)
|
||||
credentials: options.credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
};
|
||||
|
||||
for await (const msg of provider.executeQuery(providerOptions)) {
|
||||
|
||||
112
apps/server/src/providers/tool-normalization.ts
Normal file
112
apps/server/src/providers/tool-normalization.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Shared tool normalization utilities for AI providers
|
||||
*
|
||||
* These utilities help normalize tool inputs from various AI providers
|
||||
* to the standard format expected by the application.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Valid todo status values in the standard format
|
||||
*/
|
||||
type TodoStatus = 'pending' | 'in_progress' | 'completed';
|
||||
|
||||
/**
|
||||
* Set of valid status values for validation
|
||||
*/
|
||||
const VALID_STATUSES = new Set<TodoStatus>(['pending', 'in_progress', 'completed']);
|
||||
|
||||
/**
|
||||
* Todo item from various AI providers (Gemini, Copilot, etc.)
|
||||
*/
|
||||
interface ProviderTodo {
|
||||
description?: string;
|
||||
content?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard todo format used by the application
|
||||
*/
|
||||
interface NormalizedTodo {
|
||||
content: string;
|
||||
status: TodoStatus;
|
||||
activeForm: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a provider status value to a valid TodoStatus
|
||||
*/
|
||||
function normalizeStatus(status: string | undefined): TodoStatus {
|
||||
if (!status) return 'pending';
|
||||
if (status === 'cancelled' || status === 'canceled') return 'completed';
|
||||
if (VALID_STATUSES.has(status as TodoStatus)) return status as TodoStatus;
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize todos array from provider format to standard format
|
||||
*
|
||||
* Handles different formats from providers:
|
||||
* - Gemini: { description, status } with 'cancelled' as possible status
|
||||
* - Copilot: { content/description, status } with 'cancelled' as possible status
|
||||
*
|
||||
* Output format (Claude/Standard):
|
||||
* - { content, status, activeForm } where status is 'pending'|'in_progress'|'completed'
|
||||
*/
|
||||
export function normalizeTodos(todos: ProviderTodo[] | null | undefined): NormalizedTodo[] {
|
||||
if (!todos) return [];
|
||||
return todos.map((todo) => ({
|
||||
content: todo.content || todo.description || '',
|
||||
status: normalizeStatus(todo.status),
|
||||
// Use content/description as activeForm since providers may not have it
|
||||
activeForm: todo.content || todo.description || '',
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize file path parameters from various provider formats
|
||||
*
|
||||
* Different providers use different parameter names for file paths:
|
||||
* - path, file, filename, filePath -> file_path
|
||||
*/
|
||||
export function normalizeFilePathInput(input: Record<string, unknown>): Record<string, unknown> {
|
||||
const normalized = { ...input };
|
||||
if (!normalized.file_path) {
|
||||
if (input.path) normalized.file_path = input.path;
|
||||
else if (input.file) normalized.file_path = input.file;
|
||||
else if (input.filename) normalized.file_path = input.filename;
|
||||
else if (input.filePath) normalized.file_path = input.filePath;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize shell command parameters from various provider formats
|
||||
*
|
||||
* Different providers use different parameter names for commands:
|
||||
* - cmd, script -> command
|
||||
*/
|
||||
export function normalizeCommandInput(input: Record<string, unknown>): Record<string, unknown> {
|
||||
const normalized = { ...input };
|
||||
if (!normalized.command) {
|
||||
if (input.cmd) normalized.command = input.cmd;
|
||||
else if (input.script) normalized.command = input.script;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize search pattern parameters from various provider formats
|
||||
*
|
||||
* Different providers use different parameter names for search patterns:
|
||||
* - query, search, regex -> pattern
|
||||
*/
|
||||
export function normalizePatternInput(input: Record<string, unknown>): Record<string, unknown> {
|
||||
const normalized = { ...input };
|
||||
if (!normalized.pattern) {
|
||||
if (input.query) normalized.pattern = input.query;
|
||||
else if (input.search) normalized.pattern = input.search;
|
||||
else if (input.regex) normalized.pattern = input.regex;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
@@ -19,4 +19,7 @@ export type {
|
||||
InstallationStatus,
|
||||
ValidationResult,
|
||||
ModelDefinition,
|
||||
AgentDefinition,
|
||||
ReasoningEffort,
|
||||
SystemPromptPreset,
|
||||
} from '@automaker/types';
|
||||
|
||||
@@ -6,8 +6,17 @@ import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('SpecRegeneration');
|
||||
|
||||
// Types for running generation
|
||||
export type GenerationType = 'spec_regeneration' | 'feature_generation' | 'sync';
|
||||
|
||||
interface RunningGeneration {
|
||||
isRunning: boolean;
|
||||
type: GenerationType;
|
||||
startedAt: string;
|
||||
}
|
||||
|
||||
// Shared state for tracking generation status - scoped by project path
|
||||
const runningProjects = new Map<string, boolean>();
|
||||
const runningProjects = new Map<string, RunningGeneration>();
|
||||
const abortControllers = new Map<string, AbortController>();
|
||||
|
||||
/**
|
||||
@@ -17,16 +26,21 @@ export function getSpecRegenerationStatus(projectPath?: string): {
|
||||
isRunning: boolean;
|
||||
currentAbortController: AbortController | null;
|
||||
projectPath?: string;
|
||||
type?: GenerationType;
|
||||
startedAt?: string;
|
||||
} {
|
||||
if (projectPath) {
|
||||
const generation = runningProjects.get(projectPath);
|
||||
return {
|
||||
isRunning: runningProjects.get(projectPath) || false,
|
||||
isRunning: generation?.isRunning || false,
|
||||
currentAbortController: abortControllers.get(projectPath) || null,
|
||||
projectPath,
|
||||
type: generation?.type,
|
||||
startedAt: generation?.startedAt,
|
||||
};
|
||||
}
|
||||
// Fallback: check if any project is running (for backward compatibility)
|
||||
const isAnyRunning = Array.from(runningProjects.values()).some((running) => running);
|
||||
const isAnyRunning = Array.from(runningProjects.values()).some((g) => g.isRunning);
|
||||
return { isRunning: isAnyRunning, currentAbortController: null };
|
||||
}
|
||||
|
||||
@@ -46,10 +60,15 @@ export function getRunningProjectPath(): string | null {
|
||||
export function setRunningState(
|
||||
projectPath: string,
|
||||
running: boolean,
|
||||
controller: AbortController | null = null
|
||||
controller: AbortController | null = null,
|
||||
type: GenerationType = 'spec_regeneration'
|
||||
): void {
|
||||
if (running) {
|
||||
runningProjects.set(projectPath, true);
|
||||
runningProjects.set(projectPath, {
|
||||
isRunning: true,
|
||||
type,
|
||||
startedAt: new Date().toISOString(),
|
||||
});
|
||||
if (controller) {
|
||||
abortControllers.set(projectPath, controller);
|
||||
}
|
||||
@@ -59,6 +78,33 @@ export function setRunningState(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all running spec/feature generations for the running agents view
|
||||
*/
|
||||
export function getAllRunningGenerations(): Array<{
|
||||
projectPath: string;
|
||||
type: GenerationType;
|
||||
startedAt: string;
|
||||
}> {
|
||||
const results: Array<{
|
||||
projectPath: string;
|
||||
type: GenerationType;
|
||||
startedAt: string;
|
||||
}> = [];
|
||||
|
||||
for (const [projectPath, generation] of runningProjects.entries()) {
|
||||
if (generation.isRunning) {
|
||||
results.push({
|
||||
projectPath,
|
||||
type: generation.type,
|
||||
startedAt: generation.startedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to log authentication status
|
||||
*/
|
||||
|
||||
@@ -8,18 +8,82 @@
|
||||
import * as secureFs from '../../lib/secure-fs.js';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS, supportsStructuredOutput, isCodexModel } from '@automaker/types';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { streamingQuery } from '../../providers/simple-query-service.js';
|
||||
import { parseAndCreateFeatures } from './parse-and-create-features.js';
|
||||
import { extractJsonWithArray } from '../../lib/json-extractor.js';
|
||||
import { getAppSpecPath } from '@automaker/platform';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import { getAutoLoadClaudeMdSetting } from '../../lib/settings-helpers.js';
|
||||
import {
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getPromptCustomization,
|
||||
getPhaseModelWithOverrides,
|
||||
} from '../../lib/settings-helpers.js';
|
||||
import { FeatureLoader } from '../../services/feature-loader.js';
|
||||
|
||||
const logger = createLogger('SpecRegeneration');
|
||||
|
||||
const DEFAULT_MAX_FEATURES = 50;
|
||||
|
||||
/**
|
||||
* Timeout for Codex models when generating features (5 minutes).
|
||||
* Codex models are slower and need more time to generate 50+ features.
|
||||
*/
|
||||
const CODEX_FEATURE_GENERATION_TIMEOUT_MS = 300000; // 5 minutes
|
||||
|
||||
/**
|
||||
* Type for extracted features JSON response
|
||||
*/
|
||||
interface FeaturesExtractionResult {
|
||||
features: Array<{
|
||||
id: string;
|
||||
category?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
priority?: number;
|
||||
complexity?: 'simple' | 'moderate' | 'complex';
|
||||
dependencies?: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON schema for features output format (Claude/Codex structured output)
|
||||
*/
|
||||
const featuresOutputSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
features: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Unique feature identifier (kebab-case)' },
|
||||
category: { type: 'string', description: 'Feature category' },
|
||||
title: { type: 'string', description: 'Short, descriptive title' },
|
||||
description: { type: 'string', description: 'Detailed feature description' },
|
||||
priority: {
|
||||
type: 'number',
|
||||
description: 'Priority level: 1 (highest) to 5 (lowest)',
|
||||
},
|
||||
complexity: {
|
||||
type: 'string',
|
||||
enum: ['simple', 'moderate', 'complex'],
|
||||
description: 'Implementation complexity',
|
||||
},
|
||||
dependencies: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'IDs of features this depends on',
|
||||
},
|
||||
},
|
||||
required: ['id', 'title', 'description'],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['features'],
|
||||
} as const;
|
||||
|
||||
export async function generateFeaturesFromSpec(
|
||||
projectPath: string,
|
||||
events: EventEmitter,
|
||||
@@ -53,38 +117,48 @@ export async function generateFeaturesFromSpec(
|
||||
return;
|
||||
}
|
||||
|
||||
// Get customized prompts from settings
|
||||
const prompts = await getPromptCustomization(settingsService, '[FeatureGeneration]');
|
||||
|
||||
// Load existing features to prevent duplicates
|
||||
const featureLoader = new FeatureLoader();
|
||||
const existingFeatures = await featureLoader.getAll(projectPath);
|
||||
|
||||
logger.info(`Found ${existingFeatures.length} existing features to exclude from generation`);
|
||||
|
||||
// Build existing features context for the prompt
|
||||
let existingFeaturesContext = '';
|
||||
if (existingFeatures.length > 0) {
|
||||
const featuresList = existingFeatures
|
||||
.map(
|
||||
(f) =>
|
||||
`- "${f.title}" (ID: ${f.id}): ${f.description?.substring(0, 100) || 'No description'}`
|
||||
)
|
||||
.join('\n');
|
||||
existingFeaturesContext = `
|
||||
|
||||
## EXISTING FEATURES (DO NOT REGENERATE THESE)
|
||||
|
||||
The following ${existingFeatures.length} features already exist in the project. You MUST NOT generate features that duplicate or overlap with these:
|
||||
|
||||
${featuresList}
|
||||
|
||||
CRITICAL INSTRUCTIONS:
|
||||
- DO NOT generate any features with the same or similar titles as the existing features listed above
|
||||
- DO NOT generate features that cover the same functionality as existing features
|
||||
- ONLY generate NEW features that are not yet in the system
|
||||
- If a feature from the roadmap already exists, skip it entirely
|
||||
- Generate unique feature IDs that do not conflict with existing IDs: ${existingFeatures.map((f) => f.id).join(', ')}
|
||||
`;
|
||||
}
|
||||
|
||||
const prompt = `Based on this project specification:
|
||||
|
||||
${spec}
|
||||
${existingFeaturesContext}
|
||||
${prompts.appSpec.generateFeaturesFromSpecPrompt}
|
||||
|
||||
Generate a prioritized list of implementable features. For each feature provide:
|
||||
|
||||
1. **id**: A unique lowercase-hyphenated identifier
|
||||
2. **category**: Functional category (e.g., "Core", "UI", "API", "Authentication", "Database")
|
||||
3. **title**: Short descriptive title
|
||||
4. **description**: What this feature does (2-3 sentences)
|
||||
5. **priority**: 1 (high), 2 (medium), or 3 (low)
|
||||
6. **complexity**: "simple", "moderate", or "complex"
|
||||
7. **dependencies**: Array of feature IDs this depends on (can be empty)
|
||||
|
||||
Format as JSON:
|
||||
{
|
||||
"features": [
|
||||
{
|
||||
"id": "feature-id",
|
||||
"category": "Feature Category",
|
||||
"title": "Feature Title",
|
||||
"description": "What it does",
|
||||
"priority": 1,
|
||||
"complexity": "moderate",
|
||||
"dependencies": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Generate ${featureCount} features that build on each other logically.
|
||||
|
||||
IMPORTANT: Do not ask for clarification. The specification is provided above. Generate the JSON immediately.`;
|
||||
Generate ${featureCount} NEW features that build on each other logically. Remember: ONLY generate features that DO NOT already exist.`;
|
||||
|
||||
logger.info('========== PROMPT BEING SENT ==========');
|
||||
logger.info(`Prompt length: ${prompt.length} chars`);
|
||||
@@ -104,25 +178,97 @@ IMPORTANT: Do not ask for clarification. The specification is provided above. Ge
|
||||
'[FeatureGeneration]'
|
||||
);
|
||||
|
||||
// Get model from phase settings
|
||||
const settings = await settingsService?.getGlobalSettings();
|
||||
const phaseModelEntry =
|
||||
settings?.phaseModels?.featureGenerationModel || DEFAULT_PHASE_MODELS.featureGenerationModel;
|
||||
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
|
||||
// Get model from phase settings with provider info
|
||||
const {
|
||||
phaseModel: phaseModelEntry,
|
||||
provider,
|
||||
credentials,
|
||||
} = settingsService
|
||||
? await getPhaseModelWithOverrides(
|
||||
'featureGenerationModel',
|
||||
settingsService,
|
||||
projectPath,
|
||||
'[FeatureGeneration]'
|
||||
)
|
||||
: {
|
||||
phaseModel: DEFAULT_PHASE_MODELS.featureGenerationModel,
|
||||
provider: undefined,
|
||||
credentials: undefined,
|
||||
};
|
||||
const { model, thinkingLevel, reasoningEffort } = resolvePhaseModel(phaseModelEntry);
|
||||
|
||||
logger.info('Using model:', model);
|
||||
logger.info('Using model:', model, provider ? `via provider: ${provider.name}` : 'direct API');
|
||||
|
||||
// Codex models need extended timeout for generating many features.
|
||||
// Use 'xhigh' reasoning effort to get 5-minute timeout (300s base * 1.0x = 300s).
|
||||
// The Codex provider has a special 5-minute base timeout for feature generation.
|
||||
const isCodex = isCodexModel(model);
|
||||
const effectiveReasoningEffort = isCodex ? 'xhigh' : reasoningEffort;
|
||||
|
||||
if (isCodex) {
|
||||
logger.info('Codex model detected - using extended timeout (5 minutes for feature generation)');
|
||||
}
|
||||
if (effectiveReasoningEffort) {
|
||||
logger.info('Reasoning effort:', effectiveReasoningEffort);
|
||||
}
|
||||
|
||||
// Determine if we should use structured output based on model type
|
||||
const useStructuredOutput = supportsStructuredOutput(model);
|
||||
logger.info(
|
||||
`Structured output mode: ${useStructuredOutput ? 'enabled (Claude/Codex)' : 'disabled (using JSON instructions)'}`
|
||||
);
|
||||
|
||||
// Build the final prompt - for non-Claude/Codex models, include explicit JSON instructions
|
||||
let finalPrompt = prompt;
|
||||
if (!useStructuredOutput) {
|
||||
finalPrompt = `${prompt}
|
||||
|
||||
CRITICAL INSTRUCTIONS:
|
||||
1. DO NOT write any files. Return the JSON in your response only.
|
||||
2. After analyzing the spec, respond with ONLY a JSON object - no explanations, no markdown, just raw JSON.
|
||||
3. The JSON must have this exact structure:
|
||||
{
|
||||
"features": [
|
||||
{
|
||||
"id": "unique-feature-id",
|
||||
"category": "Category Name",
|
||||
"title": "Short Feature Title",
|
||||
"description": "Detailed description of the feature",
|
||||
"priority": 1,
|
||||
"complexity": "simple|moderate|complex",
|
||||
"dependencies": ["other-feature-id"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
4. Feature IDs must be unique, lowercase, kebab-case (e.g., "user-authentication", "data-export")
|
||||
5. Priority ranges from 1 (highest) to 5 (lowest)
|
||||
6. Complexity must be one of: "simple", "moderate", "complex"
|
||||
7. Dependencies is an array of feature IDs that must be completed first (can be empty)
|
||||
|
||||
Your entire response should be valid JSON starting with { and ending with }. No text before or after.`;
|
||||
}
|
||||
|
||||
// Use streamingQuery with event callbacks
|
||||
const result = await streamingQuery({
|
||||
prompt,
|
||||
prompt: finalPrompt,
|
||||
model,
|
||||
cwd: projectPath,
|
||||
maxTurns: 250,
|
||||
allowedTools: ['Read', 'Glob', 'Grep'],
|
||||
abortController,
|
||||
thinkingLevel,
|
||||
reasoningEffort: effectiveReasoningEffort, // Extended timeout for Codex models
|
||||
readOnly: true, // Feature generation only reads code, doesn't write
|
||||
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
|
||||
claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
outputFormat: useStructuredOutput
|
||||
? {
|
||||
type: 'json_schema',
|
||||
schema: featuresOutputSchema,
|
||||
}
|
||||
: undefined,
|
||||
onText: (text) => {
|
||||
logger.debug(`Feature text block received (${text.length} chars)`);
|
||||
events.emit('spec-regeneration:event', {
|
||||
@@ -133,15 +279,51 @@ IMPORTANT: Do not ask for clarification. The specification is provided above. Ge
|
||||
},
|
||||
});
|
||||
|
||||
const responseText = result.text;
|
||||
// Get response content - prefer structured output if available
|
||||
let contentForParsing: string;
|
||||
|
||||
logger.info(`Feature stream complete.`);
|
||||
logger.info(`Feature response length: ${responseText.length} chars`);
|
||||
logger.info('========== FULL RESPONSE TEXT ==========');
|
||||
logger.info(responseText);
|
||||
logger.info('========== END RESPONSE TEXT ==========');
|
||||
if (result.structured_output) {
|
||||
// Use structured output from Claude/Codex models
|
||||
logger.info('✅ Received structured output from model');
|
||||
contentForParsing = JSON.stringify(result.structured_output);
|
||||
logger.debug('Structured output:', contentForParsing);
|
||||
} else {
|
||||
// Use text response (for non-Claude/Codex models or fallback)
|
||||
// Pre-extract JSON to handle conversational text that may surround the JSON response
|
||||
// This follows the same pattern used in generate-spec.ts and validate-issue.ts
|
||||
const rawText = result.text;
|
||||
logger.info(`Feature stream complete.`);
|
||||
logger.info(`Feature response length: ${rawText.length} chars`);
|
||||
logger.info('========== FULL RESPONSE TEXT ==========');
|
||||
logger.info(rawText);
|
||||
logger.info('========== END RESPONSE TEXT ==========');
|
||||
|
||||
await parseAndCreateFeatures(projectPath, responseText, events);
|
||||
// Pre-extract JSON from response - handles conversational text around the JSON
|
||||
const extracted = extractJsonWithArray<FeaturesExtractionResult>(rawText, 'features', {
|
||||
logger,
|
||||
});
|
||||
if (extracted) {
|
||||
contentForParsing = JSON.stringify(extracted);
|
||||
logger.info('✅ Pre-extracted JSON from text response');
|
||||
} else {
|
||||
// If pre-extraction fails, we know the next step will also fail.
|
||||
// Throw an error here to avoid redundant parsing and make the failure point clearer.
|
||||
logger.error(
|
||||
'❌ Could not extract features JSON from model response. Full response text was:\n' +
|
||||
rawText
|
||||
);
|
||||
const errorMessage =
|
||||
'Failed to parse features from model response: No valid JSON with a "features" array found.';
|
||||
events.emit('spec-regeneration:event', {
|
||||
type: 'spec_regeneration_error',
|
||||
error: errorMessage,
|
||||
projectPath: projectPath,
|
||||
});
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
await parseAndCreateFeatures(projectPath, contentForParsing, events);
|
||||
|
||||
logger.debug('========== generateFeaturesFromSpec() completed ==========');
|
||||
}
|
||||
|
||||
@@ -7,21 +7,20 @@
|
||||
|
||||
import * as secureFs from '../../lib/secure-fs.js';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import {
|
||||
specOutputSchema,
|
||||
specToXml,
|
||||
getStructuredSpecPromptInstruction,
|
||||
type SpecOutput,
|
||||
} from '../../lib/app-spec-format.js';
|
||||
import { specOutputSchema, specToXml, type SpecOutput } from '../../lib/app-spec-format.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS, supportsStructuredOutput } from '@automaker/types';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { extractJson } from '../../lib/json-extractor.js';
|
||||
import { streamingQuery } from '../../providers/simple-query-service.js';
|
||||
import { generateFeaturesFromSpec } from './generate-features-from-spec.js';
|
||||
import { ensureAutomakerDir, getAppSpecPath } from '@automaker/platform';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import { getAutoLoadClaudeMdSetting } from '../../lib/settings-helpers.js';
|
||||
import {
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getPromptCustomization,
|
||||
getPhaseModelWithOverrides,
|
||||
} from '../../lib/settings-helpers.js';
|
||||
|
||||
const logger = createLogger('SpecRegeneration');
|
||||
|
||||
@@ -43,6 +42,9 @@ export async function generateSpec(
|
||||
logger.info('analyzeProject:', analyzeProject);
|
||||
logger.info('maxFeatures:', maxFeatures);
|
||||
|
||||
// Get customized prompts from settings
|
||||
const prompts = await getPromptCustomization(settingsService, '[SpecRegeneration]');
|
||||
|
||||
// Build the prompt based on whether we should analyze the project
|
||||
let analysisInstructions = '';
|
||||
let techStackDefaults = '';
|
||||
@@ -66,9 +68,7 @@ export async function generateSpec(
|
||||
Use these technologies as the foundation for the specification.`;
|
||||
}
|
||||
|
||||
const prompt = `You are helping to define a software project specification.
|
||||
|
||||
IMPORTANT: Never ask for clarification or additional information. Use the information provided and make reasonable assumptions to create the best possible specification. If details are missing, infer them based on common patterns and best practices.
|
||||
const prompt = `${prompts.appSpec.generateSpecSystemPrompt}
|
||||
|
||||
Project Overview:
|
||||
${projectOverview}
|
||||
@@ -77,7 +77,7 @@ ${techStackDefaults}
|
||||
|
||||
${analysisInstructions}
|
||||
|
||||
${getStructuredSpecPromptInstruction()}`;
|
||||
${prompts.appSpec.structuredSpecInstructions}`;
|
||||
|
||||
logger.info('========== PROMPT BEING SENT ==========');
|
||||
logger.info(`Prompt length: ${prompt.length} chars`);
|
||||
@@ -96,21 +96,37 @@ ${getStructuredSpecPromptInstruction()}`;
|
||||
'[SpecRegeneration]'
|
||||
);
|
||||
|
||||
// Get model from phase settings
|
||||
const settings = await settingsService?.getGlobalSettings();
|
||||
const phaseModelEntry =
|
||||
settings?.phaseModels?.specGenerationModel || DEFAULT_PHASE_MODELS.specGenerationModel;
|
||||
// Get model from phase settings with provider info
|
||||
const {
|
||||
phaseModel: phaseModelEntry,
|
||||
provider,
|
||||
credentials,
|
||||
} = settingsService
|
||||
? await getPhaseModelWithOverrides(
|
||||
'specGenerationModel',
|
||||
settingsService,
|
||||
projectPath,
|
||||
'[SpecRegeneration]'
|
||||
)
|
||||
: {
|
||||
phaseModel: DEFAULT_PHASE_MODELS.specGenerationModel,
|
||||
provider: undefined,
|
||||
credentials: undefined,
|
||||
};
|
||||
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
|
||||
|
||||
logger.info('Using model:', model);
|
||||
logger.info('Using model:', model, provider ? `via provider: ${provider.name}` : 'direct API');
|
||||
|
||||
let responseText = '';
|
||||
let structuredOutput: SpecOutput | null = null;
|
||||
|
||||
// Determine if we should use structured output (Claude supports it, Cursor doesn't)
|
||||
const useStructuredOutput = !isCursorModel(model);
|
||||
// Determine if we should use structured output based on model type
|
||||
const useStructuredOutput = supportsStructuredOutput(model);
|
||||
logger.info(
|
||||
`Structured output mode: ${useStructuredOutput ? 'enabled (Claude/Codex)' : 'disabled (using JSON instructions)'}`
|
||||
);
|
||||
|
||||
// Build the final prompt - for Cursor, include JSON schema instructions
|
||||
// Build the final prompt - for non-Claude/Codex models, include JSON schema instructions
|
||||
let finalPrompt = prompt;
|
||||
if (!useStructuredOutput) {
|
||||
finalPrompt = `${prompt}
|
||||
@@ -136,6 +152,8 @@ Your entire response should be valid JSON starting with { and ending with }. No
|
||||
thinkingLevel,
|
||||
readOnly: true, // Spec generation only reads code, we write the spec ourselves
|
||||
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
|
||||
claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
outputFormat: useStructuredOutput
|
||||
? {
|
||||
type: 'json_schema',
|
||||
@@ -205,19 +223,33 @@ Your entire response should be valid JSON starting with { and ending with }. No
|
||||
xmlContent = responseText.substring(xmlStart, xmlEnd + '</project_specification>'.length);
|
||||
logger.info(`Extracted XML content: ${xmlContent.length} chars (from position ${xmlStart})`);
|
||||
} else {
|
||||
// No valid XML structure found in the response text
|
||||
// This happens when structured output was expected but not received, and the agent
|
||||
// output conversational text instead of XML (e.g., "The project directory appears to be empty...")
|
||||
// We should NOT save this conversational text as it's not a valid spec
|
||||
logger.error('❌ Response does not contain valid <project_specification> XML structure');
|
||||
logger.error(
|
||||
'This typically happens when structured output failed and the agent produced conversational text instead of XML'
|
||||
);
|
||||
throw new Error(
|
||||
'Failed to generate spec: No valid XML structure found in response. ' +
|
||||
'The response contained conversational text but no <project_specification> tags. ' +
|
||||
'Please try again.'
|
||||
);
|
||||
// No XML found, try JSON extraction
|
||||
logger.warn('⚠️ No XML tags found, attempting JSON extraction...');
|
||||
const extractedJson = extractJson<SpecOutput>(responseText, { logger });
|
||||
|
||||
if (
|
||||
extractedJson &&
|
||||
typeof extractedJson.project_name === 'string' &&
|
||||
typeof extractedJson.overview === 'string' &&
|
||||
Array.isArray(extractedJson.technology_stack) &&
|
||||
Array.isArray(extractedJson.core_capabilities) &&
|
||||
Array.isArray(extractedJson.implemented_features)
|
||||
) {
|
||||
logger.info('✅ Successfully extracted JSON from response text');
|
||||
xmlContent = specToXml(extractedJson);
|
||||
logger.info(`✅ Converted extracted JSON to XML: ${xmlContent.length} chars`);
|
||||
} else {
|
||||
// Neither XML nor valid JSON found
|
||||
logger.error('❌ Response does not contain valid XML or JSON structure');
|
||||
logger.error(
|
||||
'This typically happens when structured output failed and the agent produced conversational text instead of structured output'
|
||||
);
|
||||
throw new Error(
|
||||
'Failed to generate spec: No valid XML or JSON structure found in response. ' +
|
||||
'The response contained conversational text but no <project_specification> tags or valid JSON. ' +
|
||||
'Please try again.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { EventEmitter } from '../../lib/events.js';
|
||||
import { createCreateHandler } from './routes/create.js';
|
||||
import { createGenerateHandler } from './routes/generate.js';
|
||||
import { createGenerateFeaturesHandler } from './routes/generate-features.js';
|
||||
import { createSyncHandler } from './routes/sync.js';
|
||||
import { createStopHandler } from './routes/stop.js';
|
||||
import { createStatusHandler } from './routes/status.js';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
@@ -20,6 +21,7 @@ export function createSpecRegenerationRoutes(
|
||||
router.post('/create', createCreateHandler(events));
|
||||
router.post('/generate', createGenerateHandler(events, settingsService));
|
||||
router.post('/generate-features', createGenerateFeaturesHandler(events, settingsService));
|
||||
router.post('/sync', createSyncHandler(events, settingsService));
|
||||
router.post('/stop', createStopHandler());
|
||||
router.get('/status', createStatusHandler());
|
||||
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
import path from 'path';
|
||||
import * as secureFs from '../../lib/secure-fs.js';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { createLogger, atomicWriteJson, DEFAULT_BACKUP_COUNT } from '@automaker/utils';
|
||||
import { getFeaturesDir } from '@automaker/platform';
|
||||
import { extractJsonWithArray } from '../../lib/json-extractor.js';
|
||||
import { getNotificationService } from '../../services/notification-service.js';
|
||||
|
||||
const logger = createLogger('SpecRegeneration');
|
||||
|
||||
@@ -73,10 +74,10 @@ export async function parseAndCreateFeatures(
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await secureFs.writeFile(
|
||||
path.join(featureDir, 'feature.json'),
|
||||
JSON.stringify(featureData, null, 2)
|
||||
);
|
||||
// Use atomic write with backup support for crash protection
|
||||
await atomicWriteJson(path.join(featureDir, 'feature.json'), featureData, {
|
||||
backupCount: DEFAULT_BACKUP_COUNT,
|
||||
});
|
||||
|
||||
createdFeatures.push({ id: feature.id, title: feature.title });
|
||||
}
|
||||
@@ -88,6 +89,15 @@ export async function parseAndCreateFeatures(
|
||||
message: `Spec regeneration complete! Created ${createdFeatures.length} features.`,
|
||||
projectPath: projectPath,
|
||||
});
|
||||
|
||||
// Create notification for spec generation completion
|
||||
const notificationService = getNotificationService();
|
||||
await notificationService.createNotification({
|
||||
type: 'spec_regeneration_complete',
|
||||
title: 'Spec Generation Complete',
|
||||
message: `Created ${createdFeatures.length} features from the project specification.`,
|
||||
projectPath: projectPath,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('❌ parseAndCreateFeatures() failed:');
|
||||
logger.error('Error:', error);
|
||||
|
||||
@@ -50,7 +50,7 @@ export function createGenerateFeaturesHandler(
|
||||
logAuthStatus('Before starting feature generation');
|
||||
|
||||
const abortController = new AbortController();
|
||||
setRunningState(projectPath, true, abortController);
|
||||
setRunningState(projectPath, true, abortController, 'feature_generation');
|
||||
logger.info('Starting background feature generation task...');
|
||||
|
||||
generateFeaturesFromSpec(projectPath, events, abortController, maxFeatures, settingsService)
|
||||
|
||||
76
apps/server/src/routes/app-spec/routes/sync.ts
Normal file
76
apps/server/src/routes/app-spec/routes/sync.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* POST /sync endpoint - Sync spec with codebase and features
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { EventEmitter } from '../../../lib/events.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import {
|
||||
getSpecRegenerationStatus,
|
||||
setRunningState,
|
||||
logAuthStatus,
|
||||
logError,
|
||||
getErrorMessage,
|
||||
} from '../common.js';
|
||||
import { syncSpec } from '../sync-spec.js';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
|
||||
const logger = createLogger('SpecSync');
|
||||
|
||||
export function createSyncHandler(events: EventEmitter, settingsService?: SettingsService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
logger.info('========== /sync endpoint called ==========');
|
||||
logger.debug('Request body:', JSON.stringify(req.body, null, 2));
|
||||
|
||||
try {
|
||||
const { projectPath } = req.body as {
|
||||
projectPath: string;
|
||||
};
|
||||
|
||||
logger.debug('projectPath:', projectPath);
|
||||
|
||||
if (!projectPath) {
|
||||
logger.error('Missing projectPath parameter');
|
||||
res.status(400).json({ success: false, error: 'projectPath required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { isRunning } = getSpecRegenerationStatus(projectPath);
|
||||
if (isRunning) {
|
||||
logger.warn('Generation/sync already running for project:', projectPath);
|
||||
res.json({ success: false, error: 'Operation already running for this project' });
|
||||
return;
|
||||
}
|
||||
|
||||
logAuthStatus('Before starting spec sync');
|
||||
|
||||
const abortController = new AbortController();
|
||||
setRunningState(projectPath, true, abortController, 'sync');
|
||||
logger.info('Starting background spec sync task...');
|
||||
|
||||
syncSpec(projectPath, events, abortController, settingsService)
|
||||
.then((result) => {
|
||||
logger.info('Spec sync completed successfully');
|
||||
logger.info('Result:', JSON.stringify(result, null, 2));
|
||||
})
|
||||
.catch((error) => {
|
||||
logError(error, 'Spec sync failed with error');
|
||||
events.emit('spec-regeneration:event', {
|
||||
type: 'spec_regeneration_error',
|
||||
error: getErrorMessage(error),
|
||||
projectPath,
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
logger.info('Spec sync task finished (success or error)');
|
||||
setRunningState(projectPath, false, null);
|
||||
});
|
||||
|
||||
logger.info('Returning success response (sync running in background)');
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logError(error, 'Sync route handler failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
390
apps/server/src/routes/app-spec/sync-spec.ts
Normal file
390
apps/server/src/routes/app-spec/sync-spec.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* Sync spec with current codebase and feature state
|
||||
*
|
||||
* Updates the spec file based on:
|
||||
* - Completed Automaker features
|
||||
* - Code analysis for tech stack and implementations
|
||||
* - Roadmap phase status updates
|
||||
*/
|
||||
|
||||
import * as secureFs from '../../lib/secure-fs.js';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { DEFAULT_PHASE_MODELS, supportsStructuredOutput } from '@automaker/types';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { streamingQuery } from '../../providers/simple-query-service.js';
|
||||
import { extractJson } from '../../lib/json-extractor.js';
|
||||
import { getAppSpecPath } from '@automaker/platform';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import {
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getPhaseModelWithOverrides,
|
||||
} from '../../lib/settings-helpers.js';
|
||||
import { FeatureLoader } from '../../services/feature-loader.js';
|
||||
import {
|
||||
extractImplementedFeatures,
|
||||
extractTechnologyStack,
|
||||
extractRoadmapPhases,
|
||||
updateImplementedFeaturesSection,
|
||||
updateTechnologyStack,
|
||||
updateRoadmapPhaseStatus,
|
||||
type ImplementedFeature,
|
||||
type RoadmapPhase,
|
||||
} from '../../lib/xml-extractor.js';
|
||||
import { getNotificationService } from '../../services/notification-service.js';
|
||||
|
||||
const logger = createLogger('SpecSync');
|
||||
|
||||
/**
|
||||
* Type for extracted tech stack JSON response
|
||||
*/
|
||||
interface TechStackExtractionResult {
|
||||
technologies: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON schema for tech stack analysis output (Claude/Codex structured output)
|
||||
*/
|
||||
const techStackOutputSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
technologies: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'List of technologies detected in the project',
|
||||
},
|
||||
},
|
||||
required: ['technologies'],
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Result of a sync operation
|
||||
*/
|
||||
export interface SyncResult {
|
||||
techStackUpdates: {
|
||||
added: string[];
|
||||
removed: string[];
|
||||
};
|
||||
implementedFeaturesUpdates: {
|
||||
addedFromFeatures: string[];
|
||||
removed: string[];
|
||||
};
|
||||
roadmapUpdates: Array<{ phaseName: string; newStatus: string }>;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync the spec with current codebase and feature state
|
||||
*/
|
||||
export async function syncSpec(
|
||||
projectPath: string,
|
||||
events: EventEmitter,
|
||||
abortController: AbortController,
|
||||
settingsService?: SettingsService
|
||||
): Promise<SyncResult> {
|
||||
logger.info('========== syncSpec() started ==========');
|
||||
logger.info('projectPath:', projectPath);
|
||||
|
||||
const result: SyncResult = {
|
||||
techStackUpdates: { added: [], removed: [] },
|
||||
implementedFeaturesUpdates: { addedFromFeatures: [], removed: [] },
|
||||
roadmapUpdates: [],
|
||||
summary: '',
|
||||
};
|
||||
|
||||
// Read existing spec
|
||||
const specPath = getAppSpecPath(projectPath);
|
||||
let specContent: string;
|
||||
|
||||
try {
|
||||
specContent = (await secureFs.readFile(specPath, 'utf-8')) as string;
|
||||
logger.info(`Spec loaded successfully (${specContent.length} chars)`);
|
||||
} catch (readError) {
|
||||
logger.error('Failed to read spec file:', readError);
|
||||
events.emit('spec-regeneration:event', {
|
||||
type: 'spec_regeneration_error',
|
||||
error: 'No project spec found. Create or regenerate spec first.',
|
||||
projectPath,
|
||||
});
|
||||
throw new Error('No project spec found');
|
||||
}
|
||||
|
||||
events.emit('spec-regeneration:event', {
|
||||
type: 'spec_regeneration_progress',
|
||||
content: '[Phase: sync] Starting spec sync...\n',
|
||||
projectPath,
|
||||
});
|
||||
|
||||
// Extract current state from spec
|
||||
const currentImplementedFeatures = extractImplementedFeatures(specContent);
|
||||
const currentTechStack = extractTechnologyStack(specContent);
|
||||
const currentRoadmapPhases = extractRoadmapPhases(specContent);
|
||||
|
||||
logger.info(`Current spec has ${currentImplementedFeatures.length} implemented features`);
|
||||
logger.info(`Current spec has ${currentTechStack.length} technologies`);
|
||||
logger.info(`Current spec has ${currentRoadmapPhases.length} roadmap phases`);
|
||||
|
||||
// Load completed Automaker features
|
||||
const featureLoader = new FeatureLoader();
|
||||
const allFeatures = await featureLoader.getAll(projectPath);
|
||||
const completedFeatures = allFeatures.filter(
|
||||
(f) => f.status === 'completed' || f.status === 'verified'
|
||||
);
|
||||
|
||||
logger.info(`Found ${completedFeatures.length} completed/verified features in Automaker`);
|
||||
|
||||
events.emit('spec-regeneration:event', {
|
||||
type: 'spec_regeneration_progress',
|
||||
content: `Found ${completedFeatures.length} completed features to sync...\n`,
|
||||
projectPath,
|
||||
});
|
||||
|
||||
// Build new implemented features list from completed Automaker features
|
||||
const newImplementedFeatures: ImplementedFeature[] = [];
|
||||
const existingNames = new Set(currentImplementedFeatures.map((f) => f.name.toLowerCase()));
|
||||
|
||||
for (const feature of completedFeatures) {
|
||||
const name = feature.title || `Feature: ${feature.id}`;
|
||||
if (!existingNames.has(name.toLowerCase())) {
|
||||
newImplementedFeatures.push({
|
||||
name,
|
||||
description: feature.description || '',
|
||||
});
|
||||
result.implementedFeaturesUpdates.addedFromFeatures.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
// Merge: keep existing + add new from completed features
|
||||
const mergedFeatures = [...currentImplementedFeatures, ...newImplementedFeatures];
|
||||
|
||||
// Update spec with merged features
|
||||
if (result.implementedFeaturesUpdates.addedFromFeatures.length > 0) {
|
||||
specContent = updateImplementedFeaturesSection(specContent, mergedFeatures);
|
||||
logger.info(
|
||||
`Added ${result.implementedFeaturesUpdates.addedFromFeatures.length} features to spec`
|
||||
);
|
||||
}
|
||||
|
||||
// Analyze codebase for tech stack updates using AI
|
||||
events.emit('spec-regeneration:event', {
|
||||
type: 'spec_regeneration_progress',
|
||||
content: 'Analyzing codebase for technology updates...\n',
|
||||
projectPath,
|
||||
});
|
||||
|
||||
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
|
||||
projectPath,
|
||||
settingsService,
|
||||
'[SpecSync]'
|
||||
);
|
||||
|
||||
// Get model from phase settings with provider info
|
||||
const {
|
||||
phaseModel: phaseModelEntry,
|
||||
provider,
|
||||
credentials,
|
||||
} = settingsService
|
||||
? await getPhaseModelWithOverrides(
|
||||
'specGenerationModel',
|
||||
settingsService,
|
||||
projectPath,
|
||||
'[SpecSync]'
|
||||
)
|
||||
: {
|
||||
phaseModel: DEFAULT_PHASE_MODELS.specGenerationModel,
|
||||
provider: undefined,
|
||||
credentials: undefined,
|
||||
};
|
||||
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
|
||||
|
||||
logger.info('Using model:', model, provider ? `via provider: ${provider.name}` : 'direct API');
|
||||
|
||||
// Determine if we should use structured output based on model type
|
||||
const useStructuredOutput = supportsStructuredOutput(model);
|
||||
logger.info(
|
||||
`Structured output mode: ${useStructuredOutput ? 'enabled (Claude/Codex)' : 'disabled (using JSON instructions)'}`
|
||||
);
|
||||
|
||||
// Use AI to analyze tech stack
|
||||
let techAnalysisPrompt = `Analyze this project and return ONLY a JSON object with the current technology stack.
|
||||
|
||||
Current known technologies: ${currentTechStack.join(', ')}
|
||||
|
||||
Look at package.json, config files, and source code to identify:
|
||||
- Frameworks (React, Vue, Express, etc.)
|
||||
- Languages (TypeScript, JavaScript, Python, etc.)
|
||||
- Build tools (Vite, Webpack, etc.)
|
||||
- Databases (PostgreSQL, MongoDB, etc.)
|
||||
- Key libraries and tools
|
||||
|
||||
Return ONLY this JSON format, no other text:
|
||||
{
|
||||
"technologies": ["Technology 1", "Technology 2", ...]
|
||||
}`;
|
||||
|
||||
// Add explicit JSON instructions for non-Claude/Codex models
|
||||
if (!useStructuredOutput) {
|
||||
techAnalysisPrompt = `${techAnalysisPrompt}
|
||||
|
||||
CRITICAL INSTRUCTIONS:
|
||||
1. DO NOT write any files. Return the JSON in your response only.
|
||||
2. Your entire response should be valid JSON starting with { and ending with }.
|
||||
3. No explanations, no markdown, no text before or after the JSON.`;
|
||||
}
|
||||
|
||||
try {
|
||||
const techResult = await streamingQuery({
|
||||
prompt: techAnalysisPrompt,
|
||||
model,
|
||||
cwd: projectPath,
|
||||
maxTurns: 10,
|
||||
allowedTools: ['Read', 'Glob', 'Grep'],
|
||||
abortController,
|
||||
thinkingLevel,
|
||||
readOnly: true,
|
||||
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
|
||||
claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
outputFormat: useStructuredOutput
|
||||
? {
|
||||
type: 'json_schema',
|
||||
schema: techStackOutputSchema,
|
||||
}
|
||||
: undefined,
|
||||
onText: (text) => {
|
||||
logger.debug(`Tech analysis text: ${text.substring(0, 100)}`);
|
||||
},
|
||||
});
|
||||
|
||||
// Parse tech stack from response - prefer structured output if available
|
||||
let parsedTechnologies: string[] | null = null;
|
||||
|
||||
if (techResult.structured_output) {
|
||||
// Use structured output from Claude/Codex models
|
||||
const structured = techResult.structured_output as unknown as TechStackExtractionResult;
|
||||
if (Array.isArray(structured.technologies)) {
|
||||
parsedTechnologies = structured.technologies;
|
||||
logger.info('✅ Received structured output for tech analysis');
|
||||
}
|
||||
} else {
|
||||
// Fall back to text parsing for non-Claude/Codex models
|
||||
const extracted = extractJson<TechStackExtractionResult>(techResult.text, {
|
||||
logger,
|
||||
requiredKey: 'technologies',
|
||||
requireArray: true,
|
||||
});
|
||||
if (extracted && Array.isArray(extracted.technologies)) {
|
||||
parsedTechnologies = extracted.technologies;
|
||||
logger.info('✅ Extracted tech stack from text response');
|
||||
} else {
|
||||
logger.warn('⚠️ Failed to extract tech stack JSON from response');
|
||||
}
|
||||
}
|
||||
|
||||
if (parsedTechnologies) {
|
||||
const newTechStack = parsedTechnologies;
|
||||
|
||||
// Calculate differences
|
||||
const currentSet = new Set(currentTechStack.map((t) => t.toLowerCase()));
|
||||
const newSet = new Set(newTechStack.map((t) => t.toLowerCase()));
|
||||
|
||||
for (const tech of newTechStack) {
|
||||
if (!currentSet.has(tech.toLowerCase())) {
|
||||
result.techStackUpdates.added.push(tech);
|
||||
}
|
||||
}
|
||||
|
||||
for (const tech of currentTechStack) {
|
||||
if (!newSet.has(tech.toLowerCase())) {
|
||||
result.techStackUpdates.removed.push(tech);
|
||||
}
|
||||
}
|
||||
|
||||
// Update spec with new tech stack if there are changes
|
||||
if (result.techStackUpdates.added.length > 0 || result.techStackUpdates.removed.length > 0) {
|
||||
specContent = updateTechnologyStack(specContent, newTechStack);
|
||||
logger.info(
|
||||
`Updated tech stack: +${result.techStackUpdates.added.length}, -${result.techStackUpdates.removed.length}`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to analyze tech stack:', error);
|
||||
// Continue with other sync operations
|
||||
}
|
||||
|
||||
// Update roadmap phase statuses based on completed features
|
||||
events.emit('spec-regeneration:event', {
|
||||
type: 'spec_regeneration_progress',
|
||||
content: 'Checking roadmap phase statuses...\n',
|
||||
projectPath,
|
||||
});
|
||||
|
||||
// For each phase, check if all its features are completed
|
||||
// This is a heuristic - we check if the phase name appears in any feature titles/descriptions
|
||||
for (const phase of currentRoadmapPhases) {
|
||||
if (phase.status === 'completed') continue; // Already completed
|
||||
|
||||
// Check if this phase should be marked as completed
|
||||
// A phase is considered complete if we have completed features that mention it
|
||||
const phaseNameLower = phase.name.toLowerCase();
|
||||
const relatedCompletedFeatures = completedFeatures.filter(
|
||||
(f) =>
|
||||
f.title?.toLowerCase().includes(phaseNameLower) ||
|
||||
f.description?.toLowerCase().includes(phaseNameLower) ||
|
||||
f.category?.toLowerCase().includes(phaseNameLower)
|
||||
);
|
||||
|
||||
// If we have related completed features and the phase is still pending/in_progress,
|
||||
// update it to in_progress or completed based on feature count
|
||||
if (relatedCompletedFeatures.length > 0 && phase.status !== 'completed') {
|
||||
const newStatus = 'in_progress';
|
||||
specContent = updateRoadmapPhaseStatus(specContent, phase.name, newStatus);
|
||||
result.roadmapUpdates.push({ phaseName: phase.name, newStatus });
|
||||
logger.info(`Updated phase "${phase.name}" to ${newStatus}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Save updated spec
|
||||
await secureFs.writeFile(specPath, specContent, 'utf-8');
|
||||
logger.info('Spec saved successfully');
|
||||
|
||||
// Build summary
|
||||
const summaryParts: string[] = [];
|
||||
if (result.implementedFeaturesUpdates.addedFromFeatures.length > 0) {
|
||||
summaryParts.push(
|
||||
`Added ${result.implementedFeaturesUpdates.addedFromFeatures.length} implemented features`
|
||||
);
|
||||
}
|
||||
if (result.techStackUpdates.added.length > 0) {
|
||||
summaryParts.push(`Added ${result.techStackUpdates.added.length} technologies`);
|
||||
}
|
||||
if (result.techStackUpdates.removed.length > 0) {
|
||||
summaryParts.push(`Removed ${result.techStackUpdates.removed.length} technologies`);
|
||||
}
|
||||
if (result.roadmapUpdates.length > 0) {
|
||||
summaryParts.push(`Updated ${result.roadmapUpdates.length} roadmap phases`);
|
||||
}
|
||||
|
||||
result.summary = summaryParts.length > 0 ? summaryParts.join(', ') : 'Spec is already up to date';
|
||||
|
||||
// Create notification
|
||||
const notificationService = getNotificationService();
|
||||
await notificationService.createNotification({
|
||||
type: 'spec_regeneration_complete',
|
||||
title: 'Spec Sync Complete',
|
||||
message: result.summary,
|
||||
projectPath,
|
||||
});
|
||||
|
||||
events.emit('spec-regeneration:event', {
|
||||
type: 'spec_regeneration_complete',
|
||||
message: `Spec sync complete! ${result.summary}`,
|
||||
projectPath,
|
||||
});
|
||||
|
||||
logger.info('========== syncSpec() completed ==========');
|
||||
logger.info('Summary:', result.summary);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -117,9 +117,27 @@ export function createAuthRoutes(): Router {
|
||||
*
|
||||
* Returns whether the current request is authenticated.
|
||||
* Used by the UI to determine if login is needed.
|
||||
*
|
||||
* If AUTOMAKER_AUTO_LOGIN=true is set, automatically creates a session
|
||||
* for unauthenticated requests (useful for development).
|
||||
*/
|
||||
router.get('/status', (req, res) => {
|
||||
const authenticated = isRequestAuthenticated(req);
|
||||
router.get('/status', async (req, res) => {
|
||||
let authenticated = isRequestAuthenticated(req);
|
||||
|
||||
// Auto-login for development: create session automatically if enabled
|
||||
// Only works in non-production environments as a safeguard
|
||||
if (
|
||||
!authenticated &&
|
||||
process.env.AUTOMAKER_AUTO_LOGIN === 'true' &&
|
||||
process.env.NODE_ENV !== 'production'
|
||||
) {
|
||||
const sessionToken = await createSession();
|
||||
const cookieOptions = getSessionCookieOptions();
|
||||
const cookieName = getSessionCookieName();
|
||||
res.cookie(cookieName, sessionToken, cookieOptions);
|
||||
authenticated = true;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
authenticated,
|
||||
|
||||
@@ -10,6 +10,8 @@ import { validatePathParams } from '../../middleware/validate-paths.js';
|
||||
import { createStopFeatureHandler } from './routes/stop-feature.js';
|
||||
import { createStatusHandler } from './routes/status.js';
|
||||
import { createRunFeatureHandler } from './routes/run-feature.js';
|
||||
import { createStartHandler } from './routes/start.js';
|
||||
import { createStopHandler } from './routes/stop.js';
|
||||
import { createVerifyFeatureHandler } from './routes/verify-feature.js';
|
||||
import { createResumeFeatureHandler } from './routes/resume-feature.js';
|
||||
import { createContextExistsHandler } from './routes/context-exists.js';
|
||||
@@ -22,6 +24,10 @@ import { createResumeInterruptedHandler } from './routes/resume-interrupted.js';
|
||||
export function createAutoModeRoutes(autoModeService: AutoModeService): Router {
|
||||
const router = Router();
|
||||
|
||||
// Auto loop control routes
|
||||
router.post('/start', validatePathParams('projectPath'), createStartHandler(autoModeService));
|
||||
router.post('/stop', validatePathParams('projectPath'), createStopHandler(autoModeService));
|
||||
|
||||
router.post('/stop-feature', createStopFeatureHandler(autoModeService));
|
||||
router.post('/status', validatePathParams('projectPath?'), createStatusHandler(autoModeService));
|
||||
router.post(
|
||||
|
||||
@@ -26,6 +26,24 @@ export function createRunFeatureHandler(autoModeService: AutoModeService) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check per-worktree capacity before starting
|
||||
const capacity = await autoModeService.checkWorktreeCapacity(projectPath, featureId);
|
||||
if (!capacity.hasCapacity) {
|
||||
const worktreeDesc = capacity.branchName
|
||||
? `worktree "${capacity.branchName}"`
|
||||
: 'main worktree';
|
||||
res.status(429).json({
|
||||
success: false,
|
||||
error: `Agent limit reached for ${worktreeDesc} (${capacity.currentAgents}/${capacity.maxAgents}). Wait for running tasks to complete or increase the limit.`,
|
||||
details: {
|
||||
currentAgents: capacity.currentAgents,
|
||||
maxAgents: capacity.maxAgents,
|
||||
branchName: capacity.branchName,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Start execution in background
|
||||
// executeFeature derives workDir from feature.branchName
|
||||
autoModeService
|
||||
|
||||
67
apps/server/src/routes/auto-mode/routes/start.ts
Normal file
67
apps/server/src/routes/auto-mode/routes/start.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* POST /start endpoint - Start auto mode loop for a project
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const logger = createLogger('AutoMode');
|
||||
|
||||
export function createStartHandler(autoModeService: AutoModeService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, branchName, maxConcurrency } = req.body as {
|
||||
projectPath: string;
|
||||
branchName?: string | null;
|
||||
maxConcurrency?: number;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'projectPath is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalize branchName: undefined becomes null
|
||||
const normalizedBranchName = branchName ?? null;
|
||||
const worktreeDesc = normalizedBranchName
|
||||
? `worktree ${normalizedBranchName}`
|
||||
: 'main worktree';
|
||||
|
||||
// Check if already running
|
||||
if (autoModeService.isAutoLoopRunningForProject(projectPath, normalizedBranchName)) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Auto mode is already running for ${worktreeDesc}`,
|
||||
alreadyRunning: true,
|
||||
branchName: normalizedBranchName,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Start the auto loop for this project/worktree
|
||||
const resolvedMaxConcurrency = await autoModeService.startAutoLoopForProject(
|
||||
projectPath,
|
||||
normalizedBranchName,
|
||||
maxConcurrency
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`Started auto loop for ${worktreeDesc} in project: ${projectPath} with maxConcurrency: ${resolvedMaxConcurrency}`
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Auto mode started with max ${resolvedMaxConcurrency} concurrent features`,
|
||||
branchName: normalizedBranchName,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Start auto mode failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
/**
|
||||
* POST /status endpoint - Get auto mode status
|
||||
*
|
||||
* If projectPath is provided, returns per-project status including autoloop state.
|
||||
* If no projectPath, returns global status for backward compatibility.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
@@ -9,10 +12,41 @@ import { getErrorMessage, logError } from '../common.js';
|
||||
export function createStatusHandler(autoModeService: AutoModeService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, branchName } = req.body as {
|
||||
projectPath?: string;
|
||||
branchName?: string | null;
|
||||
};
|
||||
|
||||
// If projectPath is provided, return per-project/worktree status
|
||||
if (projectPath) {
|
||||
// Normalize branchName: undefined becomes null
|
||||
const normalizedBranchName = branchName ?? null;
|
||||
const projectStatus = autoModeService.getStatusForProject(
|
||||
projectPath,
|
||||
normalizedBranchName
|
||||
);
|
||||
res.json({
|
||||
success: true,
|
||||
isRunning: projectStatus.runningCount > 0,
|
||||
isAutoLoopRunning: projectStatus.isAutoLoopRunning,
|
||||
runningFeatures: projectStatus.runningFeatures,
|
||||
runningCount: projectStatus.runningCount,
|
||||
maxConcurrency: projectStatus.maxConcurrency,
|
||||
projectPath,
|
||||
branchName: normalizedBranchName,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Fall back to global status for backward compatibility
|
||||
const status = autoModeService.getStatus();
|
||||
const activeProjects = autoModeService.getActiveAutoLoopProjects();
|
||||
const activeWorktrees = autoModeService.getActiveAutoLoopWorktrees();
|
||||
res.json({
|
||||
success: true,
|
||||
...status,
|
||||
activeAutoLoopProjects: activeProjects,
|
||||
activeAutoLoopWorktrees: activeWorktrees,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Get status failed');
|
||||
|
||||
66
apps/server/src/routes/auto-mode/routes/stop.ts
Normal file
66
apps/server/src/routes/auto-mode/routes/stop.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* POST /stop endpoint - Stop auto mode loop for a project
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const logger = createLogger('AutoMode');
|
||||
|
||||
export function createStopHandler(autoModeService: AutoModeService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, branchName } = req.body as {
|
||||
projectPath: string;
|
||||
branchName?: string | null;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'projectPath is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalize branchName: undefined becomes null
|
||||
const normalizedBranchName = branchName ?? null;
|
||||
const worktreeDesc = normalizedBranchName
|
||||
? `worktree ${normalizedBranchName}`
|
||||
: 'main worktree';
|
||||
|
||||
// Check if running
|
||||
if (!autoModeService.isAutoLoopRunningForProject(projectPath, normalizedBranchName)) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Auto mode is not running for ${worktreeDesc}`,
|
||||
wasRunning: false,
|
||||
branchName: normalizedBranchName,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop the auto loop for this project/worktree
|
||||
const runningCount = await autoModeService.stopAutoLoopForProject(
|
||||
projectPath,
|
||||
normalizedBranchName
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`Stopped auto loop for ${worktreeDesc} in project: ${projectPath}, ${runningCount} features still running`
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Auto mode stopped',
|
||||
runningFeaturesCount: runningCount,
|
||||
branchName: normalizedBranchName,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Stop auto mode failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -3,12 +3,31 @@
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { ensureAutomakerDir, getAutomakerDir } from '@automaker/platform';
|
||||
import * as secureFs from '../../lib/secure-fs.js';
|
||||
import path from 'path';
|
||||
import type { BacklogPlanResult } from '@automaker/types';
|
||||
|
||||
const logger = createLogger('BacklogPlan');
|
||||
|
||||
// State for tracking running generation
|
||||
let isRunning = false;
|
||||
let currentAbortController: AbortController | null = null;
|
||||
let runningDetails: {
|
||||
projectPath: string;
|
||||
prompt: string;
|
||||
model?: string;
|
||||
startedAt: string;
|
||||
} | null = null;
|
||||
|
||||
const BACKLOG_PLAN_FILENAME = 'backlog-plan.json';
|
||||
|
||||
export interface StoredBacklogPlan {
|
||||
savedAt: string;
|
||||
prompt: string;
|
||||
model?: string;
|
||||
result: BacklogPlanResult;
|
||||
}
|
||||
|
||||
export function getBacklogPlanStatus(): { isRunning: boolean } {
|
||||
return { isRunning };
|
||||
@@ -16,20 +35,125 @@ export function getBacklogPlanStatus(): { isRunning: boolean } {
|
||||
|
||||
export function setRunningState(running: boolean, abortController?: AbortController | null): void {
|
||||
isRunning = running;
|
||||
if (!running) {
|
||||
runningDetails = null;
|
||||
}
|
||||
if (abortController !== undefined) {
|
||||
currentAbortController = abortController;
|
||||
}
|
||||
}
|
||||
|
||||
export function setRunningDetails(
|
||||
details: {
|
||||
projectPath: string;
|
||||
prompt: string;
|
||||
model?: string;
|
||||
startedAt: string;
|
||||
} | null
|
||||
): void {
|
||||
runningDetails = details;
|
||||
}
|
||||
|
||||
export function getRunningDetails(): {
|
||||
projectPath: string;
|
||||
prompt: string;
|
||||
model?: string;
|
||||
startedAt: string;
|
||||
} | null {
|
||||
return runningDetails;
|
||||
}
|
||||
|
||||
function getBacklogPlanPath(projectPath: string): string {
|
||||
return path.join(getAutomakerDir(projectPath), BACKLOG_PLAN_FILENAME);
|
||||
}
|
||||
|
||||
export async function saveBacklogPlan(projectPath: string, plan: StoredBacklogPlan): Promise<void> {
|
||||
await ensureAutomakerDir(projectPath);
|
||||
const filePath = getBacklogPlanPath(projectPath);
|
||||
await secureFs.writeFile(filePath, JSON.stringify(plan, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
export async function loadBacklogPlan(projectPath: string): Promise<StoredBacklogPlan | null> {
|
||||
try {
|
||||
const filePath = getBacklogPlanPath(projectPath);
|
||||
const raw = await secureFs.readFile(filePath, 'utf-8');
|
||||
const parsed = JSON.parse(raw as string) as StoredBacklogPlan;
|
||||
if (!Array.isArray(parsed?.result?.changes)) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearBacklogPlan(projectPath: string): Promise<void> {
|
||||
try {
|
||||
const filePath = getBacklogPlanPath(projectPath);
|
||||
await secureFs.unlink(filePath);
|
||||
} catch {
|
||||
// ignore missing file
|
||||
}
|
||||
}
|
||||
|
||||
export function getAbortController(): AbortController | null {
|
||||
return currentAbortController;
|
||||
}
|
||||
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
/**
|
||||
* Map SDK/CLI errors to user-friendly messages
|
||||
*/
|
||||
export function mapBacklogPlanError(rawMessage: string): string {
|
||||
// Claude Code spawn failures
|
||||
if (
|
||||
rawMessage.includes('Failed to spawn Claude Code process') ||
|
||||
rawMessage.includes('spawn node ENOENT') ||
|
||||
rawMessage.includes('Claude Code executable not found') ||
|
||||
rawMessage.includes('Claude Code native binary not found')
|
||||
) {
|
||||
return 'Claude CLI could not be launched. Make sure the Claude CLI is installed and available in PATH, or check that Node.js is correctly installed. Try running "which claude" or "claude --version" in your terminal to verify.';
|
||||
}
|
||||
return String(error);
|
||||
|
||||
// Claude Code process crash
|
||||
if (rawMessage.includes('Claude Code process exited')) {
|
||||
return 'Claude exited unexpectedly. Try again. If it keeps happening, re-run `claude login` or update your API key in Setup.';
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
if (rawMessage.toLowerCase().includes('rate limit') || rawMessage.includes('429')) {
|
||||
return 'Rate limited. Please wait a moment and try again.';
|
||||
}
|
||||
|
||||
// Network errors
|
||||
if (
|
||||
rawMessage.toLowerCase().includes('network') ||
|
||||
rawMessage.toLowerCase().includes('econnrefused') ||
|
||||
rawMessage.toLowerCase().includes('timeout')
|
||||
) {
|
||||
return 'Network error. Check your internet connection and try again.';
|
||||
}
|
||||
|
||||
// Authentication errors
|
||||
if (
|
||||
rawMessage.toLowerCase().includes('not authenticated') ||
|
||||
rawMessage.toLowerCase().includes('unauthorized') ||
|
||||
rawMessage.includes('401')
|
||||
) {
|
||||
return 'Authentication failed. Please check your API key or run `claude login` to authenticate.';
|
||||
}
|
||||
|
||||
// Return original message for unknown errors
|
||||
return rawMessage;
|
||||
}
|
||||
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
let rawMessage: string;
|
||||
if (error instanceof Error) {
|
||||
rawMessage = error.message;
|
||||
} else {
|
||||
rawMessage = String(error);
|
||||
}
|
||||
return mapBacklogPlanError(rawMessage);
|
||||
}
|
||||
|
||||
export function logError(error: unknown, context: string): void {
|
||||
|
||||
@@ -17,9 +17,19 @@ 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 {
|
||||
logger,
|
||||
setRunningState,
|
||||
setRunningDetails,
|
||||
getErrorMessage,
|
||||
saveBacklogPlan,
|
||||
} from './common.js';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js';
|
||||
import {
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getPromptCustomization,
|
||||
getPhaseModelWithOverrides,
|
||||
} from '../../lib/settings-helpers.js';
|
||||
|
||||
const featureLoader = new FeatureLoader();
|
||||
|
||||
@@ -111,18 +121,42 @@ export async function generateBacklogPlan(
|
||||
content: 'Generating plan with AI...',
|
||||
});
|
||||
|
||||
// Get the model to use from settings or provided override
|
||||
// Get the model to use from settings or provided override with provider info
|
||||
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);
|
||||
let claudeCompatibleProvider: import('@automaker/types').ClaudeCompatibleProvider | undefined;
|
||||
let credentials: import('@automaker/types').Credentials | undefined;
|
||||
|
||||
if (effectiveModel) {
|
||||
// Use explicit override - resolve model alias and get credentials
|
||||
const resolved = resolvePhaseModel({ model: effectiveModel });
|
||||
effectiveModel = resolved.model;
|
||||
thinkingLevel = resolved.thinkingLevel;
|
||||
credentials = await settingsService?.getCredentials();
|
||||
} else if (settingsService) {
|
||||
// Use settings-based model with provider info
|
||||
const phaseResult = await getPhaseModelWithOverrides(
|
||||
'backlogPlanningModel',
|
||||
settingsService,
|
||||
projectPath,
|
||||
'[BacklogPlan]'
|
||||
);
|
||||
const resolved = resolvePhaseModel(phaseResult.phaseModel);
|
||||
effectiveModel = resolved.model;
|
||||
thinkingLevel = resolved.thinkingLevel;
|
||||
claudeCompatibleProvider = phaseResult.provider;
|
||||
credentials = phaseResult.credentials;
|
||||
} else {
|
||||
// Fallback to defaults
|
||||
const resolved = resolvePhaseModel(DEFAULT_PHASE_MODELS.backlogPlanningModel);
|
||||
effectiveModel = resolved.model;
|
||||
thinkingLevel = resolved.thinkingLevel;
|
||||
}
|
||||
logger.info('[BacklogPlan] Using model:', effectiveModel);
|
||||
logger.info(
|
||||
'[BacklogPlan] Using model:',
|
||||
effectiveModel,
|
||||
claudeCompatibleProvider ? `via provider: ${claudeCompatibleProvider.name}` : 'direct API'
|
||||
);
|
||||
|
||||
const provider = ProviderFactory.getProviderForModel(effectiveModel);
|
||||
// Strip provider prefix - providers expect bare model IDs
|
||||
@@ -167,6 +201,8 @@ ${userPrompt}`;
|
||||
settingSources: autoLoadClaudeMd ? ['user', 'project'] : undefined,
|
||||
readOnly: true, // Plan generation only generates text, doesn't write files
|
||||
thinkingLevel, // Pass thinking level for extended thinking
|
||||
claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
});
|
||||
|
||||
let responseText = '';
|
||||
@@ -200,6 +236,13 @@ ${userPrompt}`;
|
||||
// Parse the response
|
||||
const result = parsePlanResponse(responseText);
|
||||
|
||||
await saveBacklogPlan(projectPath, {
|
||||
savedAt: new Date().toISOString(),
|
||||
prompt,
|
||||
model: effectiveModel,
|
||||
result,
|
||||
});
|
||||
|
||||
events.emit('backlog-plan:event', {
|
||||
type: 'backlog_plan_complete',
|
||||
result,
|
||||
@@ -218,5 +261,6 @@ ${userPrompt}`;
|
||||
throw error;
|
||||
} finally {
|
||||
setRunningState(false, null);
|
||||
setRunningDetails(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { createGenerateHandler } from './routes/generate.js';
|
||||
import { createStopHandler } from './routes/stop.js';
|
||||
import { createStatusHandler } from './routes/status.js';
|
||||
import { createApplyHandler } from './routes/apply.js';
|
||||
import { createClearHandler } from './routes/clear.js';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
|
||||
export function createBacklogPlanRoutes(
|
||||
@@ -23,8 +24,9 @@ export function createBacklogPlanRoutes(
|
||||
createGenerateHandler(events, settingsService)
|
||||
);
|
||||
router.post('/stop', createStopHandler());
|
||||
router.get('/status', createStatusHandler());
|
||||
router.get('/status', validatePathParams('projectPath'), createStatusHandler());
|
||||
router.post('/apply', validatePathParams('projectPath'), createApplyHandler());
|
||||
router.post('/clear', validatePathParams('projectPath'), createClearHandler());
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import type { BacklogPlanResult, BacklogChange, Feature } from '@automaker/types';
|
||||
import { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import { getErrorMessage, logError, logger } from '../common.js';
|
||||
import { clearBacklogPlan, getErrorMessage, logError, logger } from '../common.js';
|
||||
|
||||
const featureLoader = new FeatureLoader();
|
||||
|
||||
@@ -85,8 +85,9 @@ export function createApplyHandler() {
|
||||
if (!change.feature) continue;
|
||||
|
||||
try {
|
||||
// Create the new feature
|
||||
// Create the new feature - use the AI-generated ID if provided
|
||||
const newFeature = await featureLoader.create(projectPath, {
|
||||
id: change.feature.id, // Use descriptive ID from AI if provided
|
||||
title: change.feature.title,
|
||||
description: change.feature.description || '',
|
||||
category: change.feature.category || 'Uncategorized',
|
||||
@@ -147,6 +148,17 @@ export function createApplyHandler() {
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the plan before responding
|
||||
try {
|
||||
await clearBacklogPlan(projectPath);
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[BacklogPlan] Failed to clear backlog plan after apply:`,
|
||||
getErrorMessage(error)
|
||||
);
|
||||
// Don't throw - operation succeeded, just cleanup failed
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
appliedChanges,
|
||||
|
||||
25
apps/server/src/routes/backlog-plan/routes/clear.ts
Normal file
25
apps/server/src/routes/backlog-plan/routes/clear.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* POST /clear endpoint - Clear saved backlog plan
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { clearBacklogPlan, getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createClearHandler() {
|
||||
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 required' });
|
||||
return;
|
||||
}
|
||||
|
||||
await clearBacklogPlan(projectPath);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logError(error, 'Clear backlog plan failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -4,7 +4,13 @@
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { EventEmitter } from '../../../lib/events.js';
|
||||
import { getBacklogPlanStatus, setRunningState, getErrorMessage, logError } from '../common.js';
|
||||
import {
|
||||
getBacklogPlanStatus,
|
||||
setRunningState,
|
||||
setRunningDetails,
|
||||
getErrorMessage,
|
||||
logError,
|
||||
} from '../common.js';
|
||||
import { generateBacklogPlan } from '../generate-plan.js';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
|
||||
@@ -37,20 +43,26 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se
|
||||
}
|
||||
|
||||
setRunningState(true);
|
||||
setRunningDetails({
|
||||
projectPath,
|
||||
prompt,
|
||||
model,
|
||||
startedAt: new Date().toISOString(),
|
||||
});
|
||||
const abortController = new AbortController();
|
||||
setRunningState(true, abortController);
|
||||
|
||||
// Start generation in background
|
||||
// Note: generateBacklogPlan handles its own error event emission,
|
||||
// so we only log here to avoid duplicate error toasts
|
||||
generateBacklogPlan(projectPath, prompt, events, abortController, settingsService, model)
|
||||
.catch((error) => {
|
||||
// Just log - error event already emitted by generateBacklogPlan
|
||||
logError(error, 'Generate backlog plan failed (background)');
|
||||
events.emit('backlog-plan:event', {
|
||||
type: 'backlog_plan_error',
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setRunningState(false, null);
|
||||
setRunningDetails(null);
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
|
||||
@@ -3,13 +3,15 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getBacklogPlanStatus, getErrorMessage, logError } from '../common.js';
|
||||
import { getBacklogPlanStatus, loadBacklogPlan, getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createStatusHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const status = getBacklogPlanStatus();
|
||||
res.json({ success: true, ...status });
|
||||
const projectPath = typeof req.query.projectPath === 'string' ? req.query.projectPath : '';
|
||||
const savedPlan = projectPath ? await loadBacklogPlan(projectPath) : null;
|
||||
res.json({ success: true, ...status, savedPlan });
|
||||
} catch (error) {
|
||||
logError(error, 'Get backlog plan status failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getAbortController, setRunningState, getErrorMessage, logError } from '../common.js';
|
||||
import {
|
||||
getAbortController,
|
||||
setRunningState,
|
||||
setRunningDetails,
|
||||
getErrorMessage,
|
||||
logError,
|
||||
} from '../common.js';
|
||||
|
||||
export function createStopHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
@@ -12,6 +18,7 @@ export function createStopHandler() {
|
||||
if (abortController) {
|
||||
abortController.abort();
|
||||
setRunningState(false, null);
|
||||
setRunningDetails(null);
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
|
||||
@@ -12,14 +12,17 @@
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
|
||||
import { PathNotAllowedError } from '@automaker/platform';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { simpleQuery } from '../../../providers/simple-query-service.js';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import * as path from 'path';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.js';
|
||||
import {
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getPromptCustomization,
|
||||
getPhaseModelWithOverrides,
|
||||
} from '../../../lib/settings-helpers.js';
|
||||
|
||||
const logger = createLogger('DescribeFile');
|
||||
|
||||
@@ -130,11 +133,12 @@ export function createDescribeFileHandler(
|
||||
// Get the filename for context
|
||||
const fileName = path.basename(resolvedPath);
|
||||
|
||||
// Get customized prompts from settings
|
||||
const prompts = await getPromptCustomization(settingsService, '[DescribeFile]');
|
||||
|
||||
// Build prompt with file content passed as structured data
|
||||
// The file content is included directly, not via tool invocation
|
||||
const prompt = `Analyze the following file and provide a 1-2 sentence description suitable for use as context in an AI coding assistant. Focus on what the file contains, its purpose, and why an AI agent might want to use this context in the future (e.g., "API documentation for the authentication endpoints", "Configuration file for database connections", "Coding style guidelines for the project").
|
||||
|
||||
Respond with ONLY the description text, no additional formatting, preamble, or explanation.
|
||||
const prompt = `${prompts.contextDescription.describeFilePrompt}
|
||||
|
||||
File: ${fileName}${truncated ? ' (truncated)' : ''}
|
||||
|
||||
@@ -151,15 +155,23 @@ ${contentToAnalyze}`;
|
||||
'[DescribeFile]'
|
||||
);
|
||||
|
||||
// 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));
|
||||
// Get model from phase settings with provider info
|
||||
const {
|
||||
phaseModel: phaseModelEntry,
|
||||
provider,
|
||||
credentials,
|
||||
} = await getPhaseModelWithOverrides(
|
||||
'fileDescriptionModel',
|
||||
settingsService,
|
||||
cwd,
|
||||
'[DescribeFile]'
|
||||
);
|
||||
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
|
||||
|
||||
logger.info(`Resolved model: ${model}, thinkingLevel: ${thinkingLevel}`);
|
||||
logger.info(
|
||||
`Resolved model: ${model}, thinkingLevel: ${thinkingLevel}`,
|
||||
provider ? `via provider: ${provider.name}` : 'direct API'
|
||||
);
|
||||
|
||||
// Use simpleQuery - provider abstraction handles routing to correct provider
|
||||
const result = await simpleQuery({
|
||||
@@ -171,6 +183,8 @@ ${contentToAnalyze}`;
|
||||
thinkingLevel,
|
||||
readOnly: true, // File description only reads, doesn't write
|
||||
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
|
||||
claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
});
|
||||
|
||||
const description = result.text;
|
||||
|
||||
@@ -13,13 +13,17 @@
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { createLogger, readImageAsBase64 } from '@automaker/utils';
|
||||
import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types';
|
||||
import { isCursorModel } from '@automaker/types';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { simpleQuery } from '../../../providers/simple-query-service.js';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import * as path from 'path';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.js';
|
||||
import {
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getPromptCustomization,
|
||||
getPhaseModelWithOverrides,
|
||||
} from '../../../lib/settings-helpers.js';
|
||||
|
||||
const logger = createLogger('DescribeImage');
|
||||
|
||||
@@ -270,20 +274,29 @@ export function createDescribeImageHandler(
|
||||
'[DescribeImage]'
|
||||
);
|
||||
|
||||
// Get model from phase settings
|
||||
const settings = await settingsService?.getGlobalSettings();
|
||||
const phaseModelEntry =
|
||||
settings?.phaseModels?.imageDescriptionModel || DEFAULT_PHASE_MODELS.imageDescriptionModel;
|
||||
// Get model from phase settings with provider info
|
||||
const {
|
||||
phaseModel: phaseModelEntry,
|
||||
provider,
|
||||
credentials,
|
||||
} = await getPhaseModelWithOverrides(
|
||||
'imageDescriptionModel',
|
||||
settingsService,
|
||||
cwd,
|
||||
'[DescribeImage]'
|
||||
);
|
||||
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
|
||||
|
||||
logger.info(`[${requestId}] Using model: ${model}`);
|
||||
logger.info(
|
||||
`[${requestId}] Using model: ${model}`,
|
||||
provider ? `via provider: ${provider.name}` : 'direct API'
|
||||
);
|
||||
|
||||
// Build the instruction text
|
||||
const instructionText =
|
||||
`Describe this image in 1-2 sentences suitable for use as context in an AI coding assistant. ` +
|
||||
`Focus on what the image shows and its purpose (e.g., "UI mockup showing login form with email/password fields", ` +
|
||||
`"Architecture diagram of microservices", "Screenshot of error message in terminal").\n\n` +
|
||||
`Respond with ONLY the description text, no additional formatting, preamble, or explanation.`;
|
||||
// Get customized prompts from settings
|
||||
const prompts = await getPromptCustomization(settingsService, '[DescribeImage]');
|
||||
|
||||
// Build the instruction text from centralized prompts
|
||||
const instructionText = prompts.contextDescription.describeImagePrompt;
|
||||
|
||||
// Build prompt based on provider capability
|
||||
// Some providers (like Cursor) may not support image content blocks
|
||||
@@ -323,6 +336,8 @@ export function createDescribeImageHandler(
|
||||
thinkingLevel,
|
||||
readOnly: true, // Image description only reads, doesn't write
|
||||
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
|
||||
claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
});
|
||||
|
||||
logger.info(`[${requestId}] simpleQuery completed in ${Date.now() - queryStart}ms`);
|
||||
|
||||
@@ -12,7 +12,7 @@ import { resolveModelString } from '@automaker/model-resolver';
|
||||
import { CLAUDE_MODEL_MAP, type ThinkingLevel } from '@automaker/types';
|
||||
import { simpleQuery } from '../../../providers/simple-query-service.js';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import { getPromptCustomization } from '../../../lib/settings-helpers.js';
|
||||
import { getPromptCustomization, getProviderByModelId } from '../../../lib/settings-helpers.js';
|
||||
import {
|
||||
buildUserPrompt,
|
||||
isValidEnhancementMode,
|
||||
@@ -33,6 +33,8 @@ interface EnhanceRequestBody {
|
||||
model?: string;
|
||||
/** Optional thinking level for Claude models */
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
/** Optional project path for per-project Claude API profile */
|
||||
projectPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,7 +64,7 @@ export function createEnhanceHandler(
|
||||
): (req: Request, res: Response) => Promise<void> {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { originalText, enhancementMode, model, thinkingLevel } =
|
||||
const { originalText, enhancementMode, model, thinkingLevel, projectPath } =
|
||||
req.body as EnhanceRequestBody;
|
||||
|
||||
// Validate required fields
|
||||
@@ -121,8 +123,32 @@ export function createEnhanceHandler(
|
||||
// Build the user prompt with few-shot examples
|
||||
const userPrompt = buildUserPrompt(validMode, trimmedText, true);
|
||||
|
||||
// Resolve the model - use the passed model, default to sonnet for quality
|
||||
const resolvedModel = resolveModelString(model, CLAUDE_MODEL_MAP.sonnet);
|
||||
// Check if the model is a provider model (like "GLM-4.5-Air")
|
||||
// If so, get the provider config and resolved Claude model
|
||||
let claudeCompatibleProvider: import('@automaker/types').ClaudeCompatibleProvider | undefined;
|
||||
let providerResolvedModel: string | undefined;
|
||||
let credentials = await settingsService?.getCredentials();
|
||||
|
||||
if (model && settingsService) {
|
||||
const providerResult = await getProviderByModelId(
|
||||
model,
|
||||
settingsService,
|
||||
'[EnhancePrompt]'
|
||||
);
|
||||
if (providerResult.provider) {
|
||||
claudeCompatibleProvider = providerResult.provider;
|
||||
providerResolvedModel = providerResult.resolvedModel;
|
||||
credentials = providerResult.credentials;
|
||||
logger.info(
|
||||
`Using provider "${providerResult.provider.name}" for model "${model}"` +
|
||||
(providerResolvedModel ? ` -> resolved to "${providerResolvedModel}"` : '')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the model - use provider resolved model, passed model, or default to sonnet
|
||||
const resolvedModel =
|
||||
providerResolvedModel || resolveModelString(model, CLAUDE_MODEL_MAP.sonnet);
|
||||
|
||||
logger.debug(`Using model: ${resolvedModel}`);
|
||||
|
||||
@@ -137,6 +163,8 @@ export function createEnhanceHandler(
|
||||
allowedTools: [],
|
||||
thinkingLevel,
|
||||
readOnly: true, // Prompt enhancement only generates text, doesn't write files
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
|
||||
});
|
||||
|
||||
const enhancedText = result.text;
|
||||
|
||||
19
apps/server/src/routes/event-history/common.ts
Normal file
19
apps/server/src/routes/event-history/common.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Common utilities for event history routes
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
|
||||
|
||||
/** Logger instance for event history operations */
|
||||
export const logger = createLogger('EventHistory');
|
||||
|
||||
/**
|
||||
* Extract user-friendly error message from error objects
|
||||
*/
|
||||
export { getErrorMessageShared as getErrorMessage };
|
||||
|
||||
/**
|
||||
* Log error with automatic logger binding
|
||||
*/
|
||||
export const logError = createLogError(logger);
|
||||
68
apps/server/src/routes/event-history/index.ts
Normal file
68
apps/server/src/routes/event-history/index.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Event History routes - HTTP API for event history management
|
||||
*
|
||||
* Provides endpoints for:
|
||||
* - Listing events with filtering
|
||||
* - Getting individual event details
|
||||
* - Deleting events
|
||||
* - Clearing all events
|
||||
* - Replaying events to test hooks
|
||||
*
|
||||
* Mounted at /api/event-history in the main server.
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import type { EventHistoryService } from '../../services/event-history-service.js';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import { validatePathParams } from '../../middleware/validate-paths.js';
|
||||
import { createListHandler } from './routes/list.js';
|
||||
import { createGetHandler } from './routes/get.js';
|
||||
import { createDeleteHandler } from './routes/delete.js';
|
||||
import { createClearHandler } from './routes/clear.js';
|
||||
import { createReplayHandler } from './routes/replay.js';
|
||||
|
||||
/**
|
||||
* Create event history router with all endpoints
|
||||
*
|
||||
* Endpoints:
|
||||
* - POST /list - List events with optional filtering
|
||||
* - POST /get - Get a single event by ID
|
||||
* - POST /delete - Delete an event by ID
|
||||
* - POST /clear - Clear all events for a project
|
||||
* - POST /replay - Replay an event to trigger hooks
|
||||
*
|
||||
* @param eventHistoryService - Instance of EventHistoryService
|
||||
* @param settingsService - Instance of SettingsService (for replay)
|
||||
* @returns Express Router configured with all event history endpoints
|
||||
*/
|
||||
export function createEventHistoryRoutes(
|
||||
eventHistoryService: EventHistoryService,
|
||||
settingsService: SettingsService
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
// List events with filtering
|
||||
router.post('/list', validatePathParams('projectPath'), createListHandler(eventHistoryService));
|
||||
|
||||
// Get single event
|
||||
router.post('/get', validatePathParams('projectPath'), createGetHandler(eventHistoryService));
|
||||
|
||||
// Delete event
|
||||
router.post(
|
||||
'/delete',
|
||||
validatePathParams('projectPath'),
|
||||
createDeleteHandler(eventHistoryService)
|
||||
);
|
||||
|
||||
// Clear all events
|
||||
router.post('/clear', validatePathParams('projectPath'), createClearHandler(eventHistoryService));
|
||||
|
||||
// Replay event
|
||||
router.post(
|
||||
'/replay',
|
||||
validatePathParams('projectPath'),
|
||||
createReplayHandler(eventHistoryService, settingsService)
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
33
apps/server/src/routes/event-history/routes/clear.ts
Normal file
33
apps/server/src/routes/event-history/routes/clear.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* POST /api/event-history/clear - Clear all events for a project
|
||||
*
|
||||
* Request body: { projectPath: string }
|
||||
* Response: { success: true, cleared: number }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { EventHistoryService } from '../../../services/event-history-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createClearHandler(eventHistoryService: EventHistoryService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath } = req.body as { projectPath: string };
|
||||
|
||||
if (!projectPath || typeof projectPath !== 'string') {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const cleared = await eventHistoryService.clearEvents(projectPath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
cleared,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Clear events failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
43
apps/server/src/routes/event-history/routes/delete.ts
Normal file
43
apps/server/src/routes/event-history/routes/delete.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* POST /api/event-history/delete - Delete an event by ID
|
||||
*
|
||||
* Request body: { projectPath: string, eventId: string }
|
||||
* Response: { success: true } or { success: false, error: string }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { EventHistoryService } from '../../../services/event-history-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createDeleteHandler(eventHistoryService: EventHistoryService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, eventId } = req.body as {
|
||||
projectPath: string;
|
||||
eventId: string;
|
||||
};
|
||||
|
||||
if (!projectPath || typeof projectPath !== 'string') {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!eventId || typeof eventId !== 'string') {
|
||||
res.status(400).json({ success: false, error: 'eventId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const deleted = await eventHistoryService.deleteEvent(projectPath, eventId);
|
||||
|
||||
if (!deleted) {
|
||||
res.status(404).json({ success: false, error: 'Event not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logError(error, 'Delete event failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
46
apps/server/src/routes/event-history/routes/get.ts
Normal file
46
apps/server/src/routes/event-history/routes/get.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* POST /api/event-history/get - Get a single event by ID
|
||||
*
|
||||
* Request body: { projectPath: string, eventId: string }
|
||||
* Response: { success: true, event: StoredEvent } or { success: false, error: string }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { EventHistoryService } from '../../../services/event-history-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createGetHandler(eventHistoryService: EventHistoryService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, eventId } = req.body as {
|
||||
projectPath: string;
|
||||
eventId: string;
|
||||
};
|
||||
|
||||
if (!projectPath || typeof projectPath !== 'string') {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!eventId || typeof eventId !== 'string') {
|
||||
res.status(400).json({ success: false, error: 'eventId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const event = await eventHistoryService.getEvent(projectPath, eventId);
|
||||
|
||||
if (!event) {
|
||||
res.status(404).json({ success: false, error: 'Event not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
event,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Get event failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
53
apps/server/src/routes/event-history/routes/list.ts
Normal file
53
apps/server/src/routes/event-history/routes/list.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* POST /api/event-history/list - List events for a project
|
||||
*
|
||||
* Request body: {
|
||||
* projectPath: string,
|
||||
* filter?: {
|
||||
* trigger?: EventHookTrigger,
|
||||
* featureId?: string,
|
||||
* since?: string,
|
||||
* until?: string,
|
||||
* limit?: number,
|
||||
* offset?: number
|
||||
* }
|
||||
* }
|
||||
* Response: { success: true, events: StoredEventSummary[], total: number }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { EventHistoryService } from '../../../services/event-history-service.js';
|
||||
import type { EventHistoryFilter } from '@automaker/types';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createListHandler(eventHistoryService: EventHistoryService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, filter } = req.body as {
|
||||
projectPath: string;
|
||||
filter?: EventHistoryFilter;
|
||||
};
|
||||
|
||||
if (!projectPath || typeof projectPath !== 'string') {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const events = await eventHistoryService.getEvents(projectPath, filter);
|
||||
const total = await eventHistoryService.getEventCount(projectPath, {
|
||||
...filter,
|
||||
limit: undefined,
|
||||
offset: undefined,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
events,
|
||||
total,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'List events failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
234
apps/server/src/routes/event-history/routes/replay.ts
Normal file
234
apps/server/src/routes/event-history/routes/replay.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* POST /api/event-history/replay - Replay an event to trigger hooks
|
||||
*
|
||||
* Request body: {
|
||||
* projectPath: string,
|
||||
* eventId: string,
|
||||
* hookIds?: string[] // Optional: specific hooks to run (if not provided, runs all enabled matching hooks)
|
||||
* }
|
||||
* Response: { success: true, result: EventReplayResult }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { EventHistoryService } from '../../../services/event-history-service.js';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import type { EventReplayResult, EventReplayHookResult, EventHook } from '@automaker/types';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { getErrorMessage, logError, logger } from '../common.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
/** Default timeout for shell commands (30 seconds) */
|
||||
const DEFAULT_SHELL_TIMEOUT = 30000;
|
||||
|
||||
/** Default timeout for HTTP requests (10 seconds) */
|
||||
const DEFAULT_HTTP_TIMEOUT = 10000;
|
||||
|
||||
interface HookContext {
|
||||
featureId?: string;
|
||||
featureName?: string;
|
||||
projectPath?: string;
|
||||
projectName?: string;
|
||||
error?: string;
|
||||
errorType?: string;
|
||||
timestamp: string;
|
||||
eventType: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitute {{variable}} placeholders in a string
|
||||
*/
|
||||
function substituteVariables(template: string, context: HookContext): string {
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (match, variable) => {
|
||||
const value = context[variable as keyof HookContext];
|
||||
if (value === undefined || value === null) {
|
||||
return '';
|
||||
}
|
||||
return String(value);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single hook and return the result
|
||||
*/
|
||||
async function executeHook(hook: EventHook, context: HookContext): Promise<EventReplayHookResult> {
|
||||
const hookName = hook.name || hook.id;
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
if (hook.action.type === 'shell') {
|
||||
const command = substituteVariables(hook.action.command, context);
|
||||
const timeout = hook.action.timeout || DEFAULT_SHELL_TIMEOUT;
|
||||
|
||||
logger.info(`Replaying shell hook "${hookName}": ${command}`);
|
||||
|
||||
await execAsync(command, {
|
||||
timeout,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
|
||||
return {
|
||||
hookId: hook.id,
|
||||
hookName: hook.name,
|
||||
success: true,
|
||||
durationMs: Date.now() - startTime,
|
||||
};
|
||||
} else if (hook.action.type === 'http') {
|
||||
const url = substituteVariables(hook.action.url, context);
|
||||
const method = hook.action.method || 'POST';
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (hook.action.headers) {
|
||||
for (const [key, value] of Object.entries(hook.action.headers)) {
|
||||
headers[key] = substituteVariables(value, context);
|
||||
}
|
||||
}
|
||||
|
||||
let body: string | undefined;
|
||||
if (hook.action.body) {
|
||||
body = substituteVariables(hook.action.body, context);
|
||||
} else if (method !== 'GET') {
|
||||
body = JSON.stringify({
|
||||
eventType: context.eventType,
|
||||
timestamp: context.timestamp,
|
||||
featureId: context.featureId,
|
||||
projectPath: context.projectPath,
|
||||
projectName: context.projectName,
|
||||
error: context.error,
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`Replaying HTTP hook "${hookName}": ${method} ${url}`);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), DEFAULT_HTTP_TIMEOUT);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: method !== 'GET' ? body : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
hookId: hook.id,
|
||||
hookName: hook.name,
|
||||
success: false,
|
||||
error: `HTTP ${response.status}: ${response.statusText}`,
|
||||
durationMs: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
hookId: hook.id,
|
||||
hookName: hook.name,
|
||||
success: true,
|
||||
durationMs: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
hookId: hook.id,
|
||||
hookName: hook.name,
|
||||
success: false,
|
||||
error: 'Unknown hook action type',
|
||||
durationMs: Date.now() - startTime,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.name === 'AbortError'
|
||||
? 'Request timed out'
|
||||
: error.message
|
||||
: String(error);
|
||||
|
||||
return {
|
||||
hookId: hook.id,
|
||||
hookName: hook.name,
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
durationMs: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function createReplayHandler(
|
||||
eventHistoryService: EventHistoryService,
|
||||
settingsService: SettingsService
|
||||
) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, eventId, hookIds } = req.body as {
|
||||
projectPath: string;
|
||||
eventId: string;
|
||||
hookIds?: string[];
|
||||
};
|
||||
|
||||
if (!projectPath || typeof projectPath !== 'string') {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!eventId || typeof eventId !== 'string') {
|
||||
res.status(400).json({ success: false, error: 'eventId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the event
|
||||
const event = await eventHistoryService.getEvent(projectPath, eventId);
|
||||
if (!event) {
|
||||
res.status(404).json({ success: false, error: 'Event not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Get hooks from settings
|
||||
const settings = await settingsService.getGlobalSettings();
|
||||
let hooks = settings.eventHooks || [];
|
||||
|
||||
// Filter to matching trigger and enabled hooks
|
||||
hooks = hooks.filter((h) => h.enabled && h.trigger === event.trigger);
|
||||
|
||||
// If specific hook IDs requested, filter to those
|
||||
if (hookIds && hookIds.length > 0) {
|
||||
hooks = hooks.filter((h) => hookIds.includes(h.id));
|
||||
}
|
||||
|
||||
// Build context for variable substitution
|
||||
const context: HookContext = {
|
||||
featureId: event.featureId,
|
||||
featureName: event.featureName,
|
||||
projectPath: event.projectPath,
|
||||
projectName: event.projectName,
|
||||
error: event.error,
|
||||
errorType: event.errorType,
|
||||
timestamp: event.timestamp,
|
||||
eventType: event.trigger,
|
||||
};
|
||||
|
||||
// Execute all hooks in parallel
|
||||
const hookResults = await Promise.all(hooks.map((hook) => executeHook(hook, context)));
|
||||
|
||||
const result: EventReplayResult = {
|
||||
eventId,
|
||||
hooksTriggered: hooks.length,
|
||||
hookResults,
|
||||
};
|
||||
|
||||
logger.info(`Replayed event ${eventId}: ${hooks.length} hooks triggered`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Replay event failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,9 @@
|
||||
|
||||
import { Router } from 'express';
|
||||
import { FeatureLoader } from '../../services/feature-loader.js';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import type { AutoModeService } from '../../services/auto-mode-service.js';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import { validatePathParams } from '../../middleware/validate-paths.js';
|
||||
import { createListHandler } from './routes/list.js';
|
||||
import { createGetHandler } from './routes/get.js';
|
||||
@@ -14,13 +17,28 @@ import { createBulkDeleteHandler } from './routes/bulk-delete.js';
|
||||
import { createDeleteHandler } from './routes/delete.js';
|
||||
import { createAgentOutputHandler, createRawOutputHandler } from './routes/agent-output.js';
|
||||
import { createGenerateTitleHandler } from './routes/generate-title.js';
|
||||
import { createExportHandler } from './routes/export.js';
|
||||
import { createImportHandler, createConflictCheckHandler } from './routes/import.js';
|
||||
|
||||
export function createFeaturesRoutes(featureLoader: FeatureLoader): Router {
|
||||
export function createFeaturesRoutes(
|
||||
featureLoader: FeatureLoader,
|
||||
settingsService?: SettingsService,
|
||||
events?: EventEmitter,
|
||||
autoModeService?: AutoModeService
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
router.post('/list', validatePathParams('projectPath'), createListHandler(featureLoader));
|
||||
router.post(
|
||||
'/list',
|
||||
validatePathParams('projectPath'),
|
||||
createListHandler(featureLoader, autoModeService)
|
||||
);
|
||||
router.post('/get', validatePathParams('projectPath'), createGetHandler(featureLoader));
|
||||
router.post('/create', validatePathParams('projectPath'), createCreateHandler(featureLoader));
|
||||
router.post(
|
||||
'/create',
|
||||
validatePathParams('projectPath'),
|
||||
createCreateHandler(featureLoader, events)
|
||||
);
|
||||
router.post('/update', validatePathParams('projectPath'), createUpdateHandler(featureLoader));
|
||||
router.post(
|
||||
'/bulk-update',
|
||||
@@ -35,7 +53,14 @@ export function createFeaturesRoutes(featureLoader: FeatureLoader): Router {
|
||||
router.post('/delete', validatePathParams('projectPath'), createDeleteHandler(featureLoader));
|
||||
router.post('/agent-output', createAgentOutputHandler(featureLoader));
|
||||
router.post('/raw-output', createRawOutputHandler(featureLoader));
|
||||
router.post('/generate-title', createGenerateTitleHandler());
|
||||
router.post('/generate-title', createGenerateTitleHandler(settingsService));
|
||||
router.post('/export', validatePathParams('projectPath'), createExportHandler(featureLoader));
|
||||
router.post('/import', validatePathParams('projectPath'), createImportHandler(featureLoader));
|
||||
router.post(
|
||||
'/check-conflicts',
|
||||
validatePathParams('projectPath'),
|
||||
createConflictCheckHandler(featureLoader)
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -30,19 +30,27 @@ export function createBulkDeleteHandler(featureLoader: FeatureLoader) {
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
featureIds.map(async (featureId) => {
|
||||
const success = await featureLoader.delete(projectPath, featureId);
|
||||
if (success) {
|
||||
return { featureId, success: true };
|
||||
}
|
||||
return {
|
||||
featureId,
|
||||
success: false,
|
||||
error: 'Deletion failed. Check server logs for details.',
|
||||
};
|
||||
})
|
||||
);
|
||||
// Process in parallel batches of 20 for efficiency
|
||||
const BATCH_SIZE = 20;
|
||||
const results: BulkDeleteResult[] = [];
|
||||
|
||||
for (let i = 0; i < featureIds.length; i += BATCH_SIZE) {
|
||||
const batch = featureIds.slice(i, i + BATCH_SIZE);
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(async (featureId) => {
|
||||
const success = await featureLoader.delete(projectPath, featureId);
|
||||
if (success) {
|
||||
return { featureId, success: true };
|
||||
}
|
||||
return {
|
||||
featureId,
|
||||
success: false,
|
||||
error: 'Deletion failed. Check server logs for details.',
|
||||
};
|
||||
})
|
||||
);
|
||||
results.push(...batchResults);
|
||||
}
|
||||
|
||||
const successCount = results.reduce((count, r) => count + (r.success ? 1 : 0), 0);
|
||||
const failureCount = results.length - successCount;
|
||||
|
||||
@@ -43,17 +43,36 @@ export function createBulkUpdateHandler(featureLoader: FeatureLoader) {
|
||||
const results: BulkUpdateResult[] = [];
|
||||
const updatedFeatures: Feature[] = [];
|
||||
|
||||
for (const featureId of featureIds) {
|
||||
try {
|
||||
const updated = await featureLoader.update(projectPath, featureId, updates);
|
||||
results.push({ featureId, success: true });
|
||||
updatedFeatures.push(updated);
|
||||
} catch (error) {
|
||||
results.push({
|
||||
featureId,
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
// Process in parallel batches of 20 for efficiency
|
||||
const BATCH_SIZE = 20;
|
||||
for (let i = 0; i < featureIds.length; i += BATCH_SIZE) {
|
||||
const batch = featureIds.slice(i, i + BATCH_SIZE);
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(async (featureId) => {
|
||||
try {
|
||||
const updated = await featureLoader.update(projectPath, featureId, updates);
|
||||
return { featureId, success: true as const, feature: updated };
|
||||
} catch (error) {
|
||||
return {
|
||||
featureId,
|
||||
success: false as const,
|
||||
error: getErrorMessage(error),
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
for (const result of batchResults) {
|
||||
if (result.success) {
|
||||
results.push({ featureId: result.featureId, success: true });
|
||||
updatedFeatures.push(result.feature);
|
||||
} else {
|
||||
results.push({
|
||||
featureId: result.featureId,
|
||||
success: false,
|
||||
error: result.error,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import type { EventEmitter } from '../../../lib/events.js';
|
||||
import type { Feature } from '@automaker/types';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createCreateHandler(featureLoader: FeatureLoader) {
|
||||
export function createCreateHandler(featureLoader: FeatureLoader, events?: EventEmitter) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, feature } = req.body as {
|
||||
@@ -23,7 +24,30 @@ export function createCreateHandler(featureLoader: FeatureLoader) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for duplicate title if title is provided
|
||||
if (feature.title && feature.title.trim()) {
|
||||
const duplicate = await featureLoader.findDuplicateTitle(projectPath, feature.title);
|
||||
if (duplicate) {
|
||||
res.status(409).json({
|
||||
success: false,
|
||||
error: `A feature with title "${feature.title}" already exists`,
|
||||
duplicateFeatureId: duplicate.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const created = await featureLoader.create(projectPath, feature);
|
||||
|
||||
// Emit feature_created event for hooks
|
||||
if (events) {
|
||||
events.emit('feature:created', {
|
||||
featureId: created.id,
|
||||
featureName: created.name,
|
||||
projectPath,
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ success: true, feature: created });
|
||||
} catch (error) {
|
||||
logError(error, 'Create feature failed');
|
||||
|
||||
96
apps/server/src/routes/features/routes/export.ts
Normal file
96
apps/server/src/routes/features/routes/export.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* POST /export endpoint - Export features to JSON or YAML format
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import {
|
||||
getFeatureExportService,
|
||||
type ExportFormat,
|
||||
type BulkExportOptions,
|
||||
} from '../../../services/feature-export-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
interface ExportRequest {
|
||||
projectPath: string;
|
||||
/** Feature IDs to export. If empty/undefined, exports all features */
|
||||
featureIds?: string[];
|
||||
/** Export format: 'json' or 'yaml' */
|
||||
format?: ExportFormat;
|
||||
/** Whether to include description history */
|
||||
includeHistory?: boolean;
|
||||
/** Whether to include plan spec */
|
||||
includePlanSpec?: boolean;
|
||||
/** Filter by category */
|
||||
category?: string;
|
||||
/** Filter by status */
|
||||
status?: string;
|
||||
/** Pretty print output */
|
||||
prettyPrint?: boolean;
|
||||
/** Optional metadata to include */
|
||||
metadata?: {
|
||||
projectName?: string;
|
||||
projectPath?: string;
|
||||
branch?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export function createExportHandler(featureLoader: FeatureLoader) {
|
||||
const exportService = getFeatureExportService();
|
||||
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const {
|
||||
projectPath,
|
||||
featureIds,
|
||||
format = 'json',
|
||||
includeHistory = true,
|
||||
includePlanSpec = true,
|
||||
category,
|
||||
status,
|
||||
prettyPrint = true,
|
||||
metadata,
|
||||
} = req.body as ExportRequest;
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate format
|
||||
if (format !== 'json' && format !== 'yaml') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'format must be "json" or "yaml"',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const options: BulkExportOptions = {
|
||||
format,
|
||||
includeHistory,
|
||||
includePlanSpec,
|
||||
category,
|
||||
status,
|
||||
featureIds,
|
||||
prettyPrint,
|
||||
metadata,
|
||||
};
|
||||
|
||||
const exportData = await exportService.exportFeatures(projectPath, options);
|
||||
|
||||
// Return the export data as a string in the response
|
||||
res.json({
|
||||
success: true,
|
||||
data: exportData,
|
||||
format,
|
||||
contentType: format === 'json' ? 'application/json' : 'application/x-yaml',
|
||||
filename: `features-export.${format === 'json' ? 'json' : 'yaml'}`,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Export features failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -9,11 +9,14 @@ import type { Request, Response } from 'express';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { CLAUDE_MODEL_MAP } from '@automaker/model-resolver';
|
||||
import { simpleQuery } from '../../../providers/simple-query-service.js';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import { getPromptCustomization } from '../../../lib/settings-helpers.js';
|
||||
|
||||
const logger = createLogger('GenerateTitle');
|
||||
|
||||
interface GenerateTitleRequestBody {
|
||||
description: string;
|
||||
projectPath?: string;
|
||||
}
|
||||
|
||||
interface GenerateTitleSuccessResponse {
|
||||
@@ -26,19 +29,12 @@ interface GenerateTitleErrorResponse {
|
||||
error: string;
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT = `You are a title generator. Your task is to create a concise, descriptive title (5-10 words max) for a software feature based on its description.
|
||||
|
||||
Rules:
|
||||
- Output ONLY the title, nothing else
|
||||
- Keep it short and action-oriented (e.g., "Add dark mode toggle", "Fix login validation")
|
||||
- Start with a verb when possible (Add, Fix, Update, Implement, Create, etc.)
|
||||
- No quotes, periods, or extra formatting
|
||||
- Capture the essence of the feature in a scannable way`;
|
||||
|
||||
export function createGenerateTitleHandler(): (req: Request, res: Response) => Promise<void> {
|
||||
export function createGenerateTitleHandler(
|
||||
settingsService?: SettingsService
|
||||
): (req: Request, res: Response) => Promise<void> {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { description } = req.body as GenerateTitleRequestBody;
|
||||
const { description, projectPath } = req.body as GenerateTitleRequestBody;
|
||||
|
||||
if (!description || typeof description !== 'string') {
|
||||
const response: GenerateTitleErrorResponse = {
|
||||
@@ -61,15 +57,23 @@ export function createGenerateTitleHandler(): (req: Request, res: Response) => P
|
||||
|
||||
logger.info(`Generating title for description: ${trimmedDescription.substring(0, 50)}...`);
|
||||
|
||||
// Get customized prompts from settings
|
||||
const prompts = await getPromptCustomization(settingsService, '[GenerateTitle]');
|
||||
const systemPrompt = prompts.titleGeneration.systemPrompt;
|
||||
|
||||
// Get credentials for API calls (uses hardcoded haiku model, no phase setting)
|
||||
const credentials = await settingsService?.getCredentials();
|
||||
|
||||
const userPrompt = `Generate a concise title for this feature:\n\n${trimmedDescription}`;
|
||||
|
||||
// Use simpleQuery - provider abstraction handles all the streaming/extraction
|
||||
const result = await simpleQuery({
|
||||
prompt: `${SYSTEM_PROMPT}\n\n${userPrompt}`,
|
||||
prompt: `${systemPrompt}\n\n${userPrompt}`,
|
||||
model: CLAUDE_MODEL_MAP.haiku,
|
||||
cwd: process.cwd(),
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
});
|
||||
|
||||
const title = result.text;
|
||||
|
||||
210
apps/server/src/routes/features/routes/import.ts
Normal file
210
apps/server/src/routes/features/routes/import.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* POST /import endpoint - Import features from JSON or YAML format
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import type { FeatureImportResult, Feature, FeatureExport } from '@automaker/types';
|
||||
import { getFeatureExportService } from '../../../services/feature-export-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
interface ImportRequest {
|
||||
projectPath: string;
|
||||
/** Raw JSON or YAML string containing feature data */
|
||||
data: string;
|
||||
/** Whether to overwrite existing features with same ID */
|
||||
overwrite?: boolean;
|
||||
/** Whether to preserve branch info from imported features */
|
||||
preserveBranchInfo?: boolean;
|
||||
/** Optional category to assign to all imported features */
|
||||
targetCategory?: string;
|
||||
}
|
||||
|
||||
interface ConflictCheckRequest {
|
||||
projectPath: string;
|
||||
/** Raw JSON or YAML string containing feature data */
|
||||
data: string;
|
||||
}
|
||||
|
||||
interface ConflictInfo {
|
||||
featureId: string;
|
||||
title?: string;
|
||||
existingTitle?: string;
|
||||
hasConflict: boolean;
|
||||
}
|
||||
|
||||
export function createImportHandler(featureLoader: FeatureLoader) {
|
||||
const exportService = getFeatureExportService();
|
||||
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const {
|
||||
projectPath,
|
||||
data,
|
||||
overwrite = false,
|
||||
preserveBranchInfo = false,
|
||||
targetCategory,
|
||||
} = req.body as ImportRequest;
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
res.status(400).json({ success: false, error: 'data is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect format and parse the data
|
||||
const format = exportService.detectFormat(data);
|
||||
if (!format) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid data format. Expected valid JSON or YAML.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = exportService.parseImportData(data);
|
||||
if (!parsed) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Failed to parse import data. Ensure it is valid JSON or YAML.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine if this is a single feature or bulk import
|
||||
const isBulkImport =
|
||||
'features' in parsed && Array.isArray((parsed as { features: unknown }).features);
|
||||
|
||||
let results: FeatureImportResult[];
|
||||
|
||||
if (isBulkImport) {
|
||||
// Bulk import
|
||||
results = await exportService.importFeatures(projectPath, data, {
|
||||
overwrite,
|
||||
preserveBranchInfo,
|
||||
targetCategory,
|
||||
});
|
||||
} else {
|
||||
// Single feature import - we know it's not a bulk export at this point
|
||||
// It must be either a Feature or FeatureExport
|
||||
const singleData = parsed as Feature | FeatureExport;
|
||||
|
||||
const result = await exportService.importFeature(projectPath, {
|
||||
data: singleData,
|
||||
overwrite,
|
||||
preserveBranchInfo,
|
||||
targetCategory,
|
||||
});
|
||||
results = [result];
|
||||
}
|
||||
|
||||
const successCount = results.filter((r) => r.success).length;
|
||||
const failureCount = results.filter((r) => !r.success).length;
|
||||
const allSuccessful = failureCount === 0;
|
||||
|
||||
res.json({
|
||||
success: allSuccessful,
|
||||
importedCount: successCount,
|
||||
failedCount: failureCount,
|
||||
results,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Import features failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create handler for checking conflicts before import
|
||||
*/
|
||||
export function createConflictCheckHandler(featureLoader: FeatureLoader) {
|
||||
const exportService = getFeatureExportService();
|
||||
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, data } = req.body as ConflictCheckRequest;
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
res.status(400).json({ success: false, error: 'data is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse the import data
|
||||
const format = exportService.detectFormat(data);
|
||||
if (!format) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid data format. Expected valid JSON or YAML.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = exportService.parseImportData(data);
|
||||
if (!parsed) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Failed to parse import data.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract features from the data using type guards
|
||||
let featuresToCheck: Array<{ id: string; title?: string }> = [];
|
||||
|
||||
if (exportService.isBulkExport(parsed)) {
|
||||
// Bulk export format
|
||||
featuresToCheck = parsed.features.map((f) => ({
|
||||
id: f.feature.id,
|
||||
title: f.feature.title,
|
||||
}));
|
||||
} else if (exportService.isFeatureExport(parsed)) {
|
||||
// Single FeatureExport format
|
||||
featuresToCheck = [
|
||||
{
|
||||
id: parsed.feature.id,
|
||||
title: parsed.feature.title,
|
||||
},
|
||||
];
|
||||
} else if (exportService.isRawFeature(parsed)) {
|
||||
// Raw Feature format
|
||||
featuresToCheck = [{ id: parsed.id, title: parsed.title }];
|
||||
}
|
||||
|
||||
// Check each feature for conflicts in parallel
|
||||
const conflicts: ConflictInfo[] = await Promise.all(
|
||||
featuresToCheck.map(async (feature) => {
|
||||
const existing = await featureLoader.get(projectPath, feature.id);
|
||||
return {
|
||||
featureId: feature.id,
|
||||
title: feature.title,
|
||||
existingTitle: existing?.title,
|
||||
hasConflict: !!existing,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const hasConflicts = conflicts.some((c) => c.hasConflict);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
hasConflicts,
|
||||
conflicts,
|
||||
totalFeatures: featuresToCheck.length,
|
||||
conflictCount: conflicts.filter((c) => c.hasConflict).length,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Conflict check failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,12 +1,19 @@
|
||||
/**
|
||||
* POST /list endpoint - List all features for a project
|
||||
*
|
||||
* Also performs orphan detection when a project is loaded to identify
|
||||
* features whose branches no longer exist. This runs on every project load/switch.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
export function createListHandler(featureLoader: FeatureLoader) {
|
||||
const logger = createLogger('FeaturesListRoute');
|
||||
|
||||
export function createListHandler(featureLoader: FeatureLoader, autoModeService?: AutoModeService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath } = req.body as { projectPath: string };
|
||||
@@ -17,6 +24,26 @@ export function createListHandler(featureLoader: FeatureLoader) {
|
||||
}
|
||||
|
||||
const features = await featureLoader.getAll(projectPath);
|
||||
|
||||
// Run orphan detection in background when project is loaded
|
||||
// This detects features whose branches no longer exist (e.g., after merge/delete)
|
||||
// We don't await this to keep the list response fast
|
||||
// Note: detectOrphanedFeatures handles errors internally and always resolves
|
||||
if (autoModeService) {
|
||||
autoModeService.detectOrphanedFeatures(projectPath).then((orphanedFeatures) => {
|
||||
if (orphanedFeatures.length > 0) {
|
||||
logger.info(
|
||||
`[ProjectLoad] Detected ${orphanedFeatures.length} orphaned feature(s) in ${projectPath}`
|
||||
);
|
||||
for (const { feature, missingBranch } of orphanedFeatures) {
|
||||
logger.info(
|
||||
`[ProjectLoad] Orphaned: ${feature.title || feature.id} - branch "${missingBranch}" no longer exists`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ success: true, features });
|
||||
} catch (error) {
|
||||
logError(error, 'List features failed');
|
||||
|
||||
@@ -4,8 +4,14 @@
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import type { Feature } from '@automaker/types';
|
||||
import type { Feature, FeatureStatus } from '@automaker/types';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('features/update');
|
||||
|
||||
// Statuses that should trigger syncing to app_spec.txt
|
||||
const SYNC_TRIGGER_STATUSES: FeatureStatus[] = ['verified', 'completed'];
|
||||
|
||||
export function createUpdateHandler(featureLoader: FeatureLoader) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -34,6 +40,28 @@ export function createUpdateHandler(featureLoader: FeatureLoader) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for duplicate title if title is being updated
|
||||
if (updates.title && updates.title.trim()) {
|
||||
const duplicate = await featureLoader.findDuplicateTitle(
|
||||
projectPath,
|
||||
updates.title,
|
||||
featureId // Exclude the current feature from duplicate check
|
||||
);
|
||||
if (duplicate) {
|
||||
res.status(409).json({
|
||||
success: false,
|
||||
error: `A feature with title "${updates.title}" already exists`,
|
||||
duplicateFeatureId: duplicate.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the current feature to detect status changes
|
||||
const currentFeature = await featureLoader.get(projectPath, featureId);
|
||||
const previousStatus = currentFeature?.status as FeatureStatus | undefined;
|
||||
const newStatus = updates.status as FeatureStatus | undefined;
|
||||
|
||||
const updated = await featureLoader.update(
|
||||
projectPath,
|
||||
featureId,
|
||||
@@ -42,6 +70,22 @@ export function createUpdateHandler(featureLoader: FeatureLoader) {
|
||||
enhancementMode,
|
||||
preEnhancementDescription
|
||||
);
|
||||
|
||||
// Trigger sync to app_spec.txt when status changes to verified or completed
|
||||
if (newStatus && SYNC_TRIGGER_STATUSES.includes(newStatus) && previousStatus !== newStatus) {
|
||||
try {
|
||||
const synced = await featureLoader.syncFeatureToAppSpec(projectPath, updated);
|
||||
if (synced) {
|
||||
logger.info(
|
||||
`Synced feature "${updated.title || updated.id}" to app_spec.txt on status change to ${newStatus}`
|
||||
);
|
||||
}
|
||||
} catch (syncError) {
|
||||
// Log the sync error but don't fail the update operation
|
||||
logger.error(`Failed to sync feature to app_spec.txt:`, syncError);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true, feature: updated });
|
||||
} catch (error) {
|
||||
logError(error, 'Update feature failed');
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
/**
|
||||
* GET /image endpoint - Serve image files
|
||||
*
|
||||
* Requires authentication via auth middleware:
|
||||
* - apiKey query parameter (Electron mode)
|
||||
* - token query parameter (web mode)
|
||||
* - session cookie (web mode)
|
||||
* - X-API-Key header (Electron mode)
|
||||
* - X-Session-Token header (web mode)
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
isCodexModel,
|
||||
isCursorModel,
|
||||
isOpencodeModel,
|
||||
supportsStructuredOutput,
|
||||
} from '@automaker/types';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { extractJson } from '../../../lib/json-extractor.js';
|
||||
@@ -30,11 +31,15 @@ import { writeValidation } from '../../../lib/validation-storage.js';
|
||||
import { streamingQuery } from '../../../providers/simple-query-service.js';
|
||||
import {
|
||||
issueValidationSchema,
|
||||
ISSUE_VALIDATION_SYSTEM_PROMPT,
|
||||
buildValidationPrompt,
|
||||
ValidationComment,
|
||||
ValidationLinkedPR,
|
||||
} from './validation-schema.js';
|
||||
import {
|
||||
getPromptCustomization,
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getProviderByModelId,
|
||||
} from '../../../lib/settings-helpers.js';
|
||||
import {
|
||||
trySetValidationRunning,
|
||||
clearValidationStatus,
|
||||
@@ -43,7 +48,6 @@ import {
|
||||
logger,
|
||||
} from './validation-common.js';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.js';
|
||||
|
||||
/**
|
||||
* Request body for issue validation
|
||||
@@ -117,13 +121,18 @@ async function runValidation(
|
||||
|
||||
let responseText = '';
|
||||
|
||||
// Determine if we should use structured output (Claude/Codex support it, Cursor/OpenCode don't)
|
||||
const useStructuredOutput = isClaudeModel(model) || isCodexModel(model);
|
||||
// Get customized prompts from settings
|
||||
const prompts = await getPromptCustomization(settingsService, '[ValidateIssue]');
|
||||
const issueValidationSystemPrompt = prompts.issueValidation.systemPrompt;
|
||||
|
||||
// Determine if we should use structured output based on model type
|
||||
// Claude and Codex support it; Cursor, Gemini, OpenCode, Copilot don't
|
||||
const useStructuredOutput = supportsStructuredOutput(model);
|
||||
|
||||
// Build the final prompt - for Cursor, include system prompt and JSON schema instructions
|
||||
let finalPrompt = basePrompt;
|
||||
if (!useStructuredOutput) {
|
||||
finalPrompt = `${ISSUE_VALIDATION_SYSTEM_PROMPT}
|
||||
finalPrompt = `${issueValidationSystemPrompt}
|
||||
|
||||
CRITICAL INSTRUCTIONS:
|
||||
1. DO NOT write any files. Return the JSON in your response only.
|
||||
@@ -160,19 +169,42 @@ ${basePrompt}`;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Using model: ${model}`);
|
||||
// Check if the model is a provider model (like "GLM-4.5-Air")
|
||||
// If so, get the provider config and resolved Claude model
|
||||
let claudeCompatibleProvider: import('@automaker/types').ClaudeCompatibleProvider | undefined;
|
||||
let providerResolvedModel: string | undefined;
|
||||
let credentials = await settingsService?.getCredentials();
|
||||
|
||||
if (settingsService) {
|
||||
const providerResult = await getProviderByModelId(model, settingsService, '[ValidateIssue]');
|
||||
if (providerResult.provider) {
|
||||
claudeCompatibleProvider = providerResult.provider;
|
||||
providerResolvedModel = providerResult.resolvedModel;
|
||||
credentials = providerResult.credentials;
|
||||
logger.info(
|
||||
`Using provider "${providerResult.provider.name}" for model "${model}"` +
|
||||
(providerResolvedModel ? ` -> resolved to "${providerResolvedModel}"` : '')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Use provider resolved model if available, otherwise use original model
|
||||
const effectiveModel = providerResolvedModel || (model as string);
|
||||
logger.info(`Using model: ${effectiveModel}`);
|
||||
|
||||
// Use streamingQuery with event callbacks
|
||||
const result = await streamingQuery({
|
||||
prompt: finalPrompt,
|
||||
model: model as string,
|
||||
model: effectiveModel,
|
||||
cwd: projectPath,
|
||||
systemPrompt: useStructuredOutput ? ISSUE_VALIDATION_SYSTEM_PROMPT : undefined,
|
||||
systemPrompt: useStructuredOutput ? issueValidationSystemPrompt : undefined,
|
||||
abortController,
|
||||
thinkingLevel: effectiveThinkingLevel,
|
||||
reasoningEffort: effectiveReasoningEffort,
|
||||
readOnly: true, // Issue validation only reads code, doesn't write
|
||||
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
|
||||
claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
outputFormat: useStructuredOutput
|
||||
? {
|
||||
type: 'json_schema',
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
/**
|
||||
* Issue Validation Schema and System Prompt
|
||||
* Issue Validation Schema and Prompt Building
|
||||
*
|
||||
* Defines the JSON schema for Claude's structured output and
|
||||
* the system prompt that guides the validation process.
|
||||
* helper functions for building validation prompts.
|
||||
*
|
||||
* Note: The system prompt is now centralized in @automaker/prompts
|
||||
* and accessed via getPromptCustomization() in validate-issue.ts
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -82,76 +85,6 @@ export const issueValidationSchema = {
|
||||
additionalProperties: false,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* System prompt that guides Claude in validating GitHub issues.
|
||||
* Instructs the model to use read-only tools to analyze the codebase.
|
||||
*/
|
||||
export const ISSUE_VALIDATION_SYSTEM_PROMPT = `You are an expert code analyst validating GitHub issues against a codebase.
|
||||
|
||||
Your task is to analyze a GitHub issue and determine if it's valid by scanning the codebase.
|
||||
|
||||
## Validation Process
|
||||
|
||||
1. **Read the issue carefully** - Understand what is being reported or requested
|
||||
2. **Search the codebase** - Use Glob to find relevant files by pattern, Grep to search for keywords
|
||||
3. **Examine the code** - Use Read to look at the actual implementation in relevant files
|
||||
4. **Check linked PRs** - If there are linked pull requests, use \`gh pr diff <PR_NUMBER>\` to review the changes
|
||||
5. **Form your verdict** - Based on your analysis, determine if the issue is valid
|
||||
|
||||
## Verdicts
|
||||
|
||||
- **valid**: The issue describes a real problem that exists in the codebase, or a clear feature request that can be implemented. The referenced files/components exist and the issue is actionable.
|
||||
|
||||
- **invalid**: The issue describes behavior that doesn't exist, references non-existent files or components, is based on a misunderstanding of the code, or the described "bug" is actually expected behavior.
|
||||
|
||||
- **needs_clarification**: The issue lacks sufficient detail to verify. Specify what additional information is needed in the missingInfo field.
|
||||
|
||||
## For Bug Reports, Check:
|
||||
- Do the referenced files/components exist?
|
||||
- Does the code match what the issue describes?
|
||||
- Is the described behavior actually a bug or expected?
|
||||
- Can you locate the code that would cause the reported issue?
|
||||
|
||||
## For Feature Requests, Check:
|
||||
- Does the feature already exist?
|
||||
- Is the implementation location clear?
|
||||
- Is the request technically feasible given the codebase structure?
|
||||
|
||||
## Analyzing Linked Pull Requests
|
||||
|
||||
When an issue has linked PRs (especially open ones), you MUST analyze them:
|
||||
|
||||
1. **Run \`gh pr diff <PR_NUMBER>\`** to see what changes the PR makes
|
||||
2. **Run \`gh pr view <PR_NUMBER>\`** to see PR description and status
|
||||
3. **Evaluate if the PR fixes the issue** - Does the diff address the reported problem?
|
||||
4. **Provide a recommendation**:
|
||||
- \`wait_for_merge\`: The PR appears to fix the issue correctly. No additional work needed - just wait for it to be merged.
|
||||
- \`pr_needs_work\`: The PR attempts to fix the issue but is incomplete or has problems.
|
||||
- \`no_pr\`: No relevant PR exists for this issue.
|
||||
|
||||
5. **Include prAnalysis in your response** with:
|
||||
- hasOpenPR: true/false
|
||||
- prFixesIssue: true/false (based on diff analysis)
|
||||
- prNumber: the PR number you analyzed
|
||||
- prSummary: brief description of what the PR changes
|
||||
- recommendation: one of the above values
|
||||
|
||||
## Response Guidelines
|
||||
|
||||
- **Always include relatedFiles** when you find relevant code
|
||||
- **Set bugConfirmed to true** only if you can definitively confirm a bug exists in the code
|
||||
- **Provide a suggestedFix** when you have a clear idea of how to address the issue
|
||||
- **Use missingInfo** when the verdict is needs_clarification to list what's needed
|
||||
- **Include prAnalysis** when there are linked PRs - this is critical for avoiding duplicate work
|
||||
- **Set estimatedComplexity** to help prioritize:
|
||||
- trivial: Simple text changes, one-line fixes
|
||||
- simple: Small changes to one file
|
||||
- moderate: Changes to multiple files or moderate logic changes
|
||||
- complex: Significant refactoring or new feature implementation
|
||||
- very_complex: Major architectural changes or cross-cutting concerns
|
||||
|
||||
Be thorough in your analysis but focus on files that are directly relevant to the issue.`;
|
||||
|
||||
/**
|
||||
* Comment data structure for validation prompt
|
||||
*/
|
||||
|
||||
@@ -9,12 +9,14 @@ import type { Request, Response } from 'express';
|
||||
|
||||
export interface EnvironmentResponse {
|
||||
isContainerized: boolean;
|
||||
skipSandboxWarning?: boolean;
|
||||
}
|
||||
|
||||
export function createEnvironmentHandler() {
|
||||
return (_req: Request, res: Response): void => {
|
||||
res.json({
|
||||
isContainerized: process.env.IS_CONTAINERIZED === 'true',
|
||||
skipSandboxWarning: process.env.AUTOMAKER_SKIP_SANDBOX_WARNING === 'true',
|
||||
} satisfies EnvironmentResponse);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,15 +4,21 @@
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { IdeationService } from '../../../services/ideation-service.js';
|
||||
import type { IdeationContextSources } from '@automaker/types';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const logger = createLogger('ideation:suggestions-generate');
|
||||
|
||||
/**
|
||||
* Creates an Express route handler for generating AI-powered ideation suggestions.
|
||||
* Accepts a prompt, category, and optional context sources configuration,
|
||||
* then returns structured suggestions that can be added to the board.
|
||||
*/
|
||||
export function createSuggestionsGenerateHandler(ideationService: IdeationService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, promptId, category, count } = req.body;
|
||||
const { projectPath, promptId, category, count, contextSources } = req.body;
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
@@ -38,7 +44,8 @@ export function createSuggestionsGenerateHandler(ideationService: IdeationServic
|
||||
projectPath,
|
||||
promptId,
|
||||
category,
|
||||
suggestionCount
|
||||
suggestionCount,
|
||||
contextSources as IdeationContextSources | undefined
|
||||
);
|
||||
|
||||
res.json({
|
||||
|
||||
21
apps/server/src/routes/notifications/common.ts
Normal file
21
apps/server/src/routes/notifications/common.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Common utilities for notification routes
|
||||
*
|
||||
* Provides logger and error handling utilities shared across all notification endpoints.
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
|
||||
|
||||
/** Logger instance for notification-related operations */
|
||||
export const logger = createLogger('Notifications');
|
||||
|
||||
/**
|
||||
* Extract user-friendly error message from error objects
|
||||
*/
|
||||
export { getErrorMessageShared as getErrorMessage };
|
||||
|
||||
/**
|
||||
* Log error with automatic logger binding
|
||||
*/
|
||||
export const logError = createLogError(logger);
|
||||
62
apps/server/src/routes/notifications/index.ts
Normal file
62
apps/server/src/routes/notifications/index.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Notifications routes - HTTP API for project-level notifications
|
||||
*
|
||||
* Provides endpoints for:
|
||||
* - Listing notifications
|
||||
* - Getting unread count
|
||||
* - Marking notifications as read
|
||||
* - Dismissing notifications
|
||||
*
|
||||
* All endpoints use handler factories that receive the NotificationService instance.
|
||||
* Mounted at /api/notifications in the main server.
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import type { NotificationService } from '../../services/notification-service.js';
|
||||
import { validatePathParams } from '../../middleware/validate-paths.js';
|
||||
import { createListHandler } from './routes/list.js';
|
||||
import { createUnreadCountHandler } from './routes/unread-count.js';
|
||||
import { createMarkReadHandler } from './routes/mark-read.js';
|
||||
import { createDismissHandler } from './routes/dismiss.js';
|
||||
|
||||
/**
|
||||
* Create notifications router with all endpoints
|
||||
*
|
||||
* Endpoints:
|
||||
* - POST /list - List all notifications for a project
|
||||
* - POST /unread-count - Get unread notification count
|
||||
* - POST /mark-read - Mark notification(s) as read
|
||||
* - POST /dismiss - Dismiss notification(s)
|
||||
*
|
||||
* @param notificationService - Instance of NotificationService
|
||||
* @returns Express Router configured with all notification endpoints
|
||||
*/
|
||||
export function createNotificationsRoutes(notificationService: NotificationService): Router {
|
||||
const router = Router();
|
||||
|
||||
// List notifications
|
||||
router.post('/list', validatePathParams('projectPath'), createListHandler(notificationService));
|
||||
|
||||
// Get unread count
|
||||
router.post(
|
||||
'/unread-count',
|
||||
validatePathParams('projectPath'),
|
||||
createUnreadCountHandler(notificationService)
|
||||
);
|
||||
|
||||
// Mark as read (single or all)
|
||||
router.post(
|
||||
'/mark-read',
|
||||
validatePathParams('projectPath'),
|
||||
createMarkReadHandler(notificationService)
|
||||
);
|
||||
|
||||
// Dismiss (single or all)
|
||||
router.post(
|
||||
'/dismiss',
|
||||
validatePathParams('projectPath'),
|
||||
createDismissHandler(notificationService)
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
53
apps/server/src/routes/notifications/routes/dismiss.ts
Normal file
53
apps/server/src/routes/notifications/routes/dismiss.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* POST /api/notifications/dismiss - Dismiss notification(s)
|
||||
*
|
||||
* Request body: { projectPath: string, notificationId?: string }
|
||||
* - If notificationId provided: dismisses that notification
|
||||
* - If notificationId not provided: dismisses all notifications
|
||||
*
|
||||
* Response: { success: true, dismissed: boolean | count: number }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { NotificationService } from '../../../services/notification-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
/**
|
||||
* Create handler for POST /api/notifications/dismiss
|
||||
*
|
||||
* @param notificationService - Instance of NotificationService
|
||||
* @returns Express request handler
|
||||
*/
|
||||
export function createDismissHandler(notificationService: NotificationService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, notificationId } = req.body;
|
||||
|
||||
if (!projectPath || typeof projectPath !== 'string') {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// If notificationId provided, dismiss single notification
|
||||
if (notificationId) {
|
||||
const dismissed = await notificationService.dismissNotification(
|
||||
projectPath,
|
||||
notificationId
|
||||
);
|
||||
if (!dismissed) {
|
||||
res.status(404).json({ success: false, error: 'Notification not found' });
|
||||
return;
|
||||
}
|
||||
res.json({ success: true, dismissed: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise dismiss all
|
||||
const count = await notificationService.dismissAll(projectPath);
|
||||
res.json({ success: true, count });
|
||||
} catch (error) {
|
||||
logError(error, 'Dismiss failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
39
apps/server/src/routes/notifications/routes/list.ts
Normal file
39
apps/server/src/routes/notifications/routes/list.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* POST /api/notifications/list - List all notifications for a project
|
||||
*
|
||||
* Request body: { projectPath: string }
|
||||
* Response: { success: true, notifications: Notification[] }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { NotificationService } from '../../../services/notification-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
/**
|
||||
* Create handler for POST /api/notifications/list
|
||||
*
|
||||
* @param notificationService - Instance of NotificationService
|
||||
* @returns Express request handler
|
||||
*/
|
||||
export function createListHandler(notificationService: NotificationService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath } = req.body;
|
||||
|
||||
if (!projectPath || typeof projectPath !== 'string') {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const notifications = await notificationService.getNotifications(projectPath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
notifications,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'List notifications failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
50
apps/server/src/routes/notifications/routes/mark-read.ts
Normal file
50
apps/server/src/routes/notifications/routes/mark-read.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* POST /api/notifications/mark-read - Mark notification(s) as read
|
||||
*
|
||||
* Request body: { projectPath: string, notificationId?: string }
|
||||
* - If notificationId provided: marks that notification as read
|
||||
* - If notificationId not provided: marks all notifications as read
|
||||
*
|
||||
* Response: { success: true, count?: number, notification?: Notification }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { NotificationService } from '../../../services/notification-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
/**
|
||||
* Create handler for POST /api/notifications/mark-read
|
||||
*
|
||||
* @param notificationService - Instance of NotificationService
|
||||
* @returns Express request handler
|
||||
*/
|
||||
export function createMarkReadHandler(notificationService: NotificationService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, notificationId } = req.body;
|
||||
|
||||
if (!projectPath || typeof projectPath !== 'string') {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// If notificationId provided, mark single notification
|
||||
if (notificationId) {
|
||||
const notification = await notificationService.markAsRead(projectPath, notificationId);
|
||||
if (!notification) {
|
||||
res.status(404).json({ success: false, error: 'Notification not found' });
|
||||
return;
|
||||
}
|
||||
res.json({ success: true, notification });
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise mark all as read
|
||||
const count = await notificationService.markAllAsRead(projectPath);
|
||||
res.json({ success: true, count });
|
||||
} catch (error) {
|
||||
logError(error, 'Mark read failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
39
apps/server/src/routes/notifications/routes/unread-count.ts
Normal file
39
apps/server/src/routes/notifications/routes/unread-count.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* POST /api/notifications/unread-count - Get unread notification count
|
||||
*
|
||||
* Request body: { projectPath: string }
|
||||
* Response: { success: true, count: number }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { NotificationService } from '../../../services/notification-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
/**
|
||||
* Create handler for POST /api/notifications/unread-count
|
||||
*
|
||||
* @param notificationService - Instance of NotificationService
|
||||
* @returns Express request handler
|
||||
*/
|
||||
export function createUnreadCountHandler(notificationService: NotificationService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath } = req.body;
|
||||
|
||||
if (!projectPath || typeof projectPath !== 'string') {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const count = await notificationService.getUnreadCount(projectPath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
count,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Get unread count failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
12
apps/server/src/routes/projects/common.ts
Normal file
12
apps/server/src/routes/projects/common.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Common utilities for projects routes
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
|
||||
|
||||
const logger = createLogger('Projects');
|
||||
|
||||
// Re-export shared utilities
|
||||
export { getErrorMessageShared as getErrorMessage };
|
||||
export const logError = createLogError(logger);
|
||||
27
apps/server/src/routes/projects/index.ts
Normal file
27
apps/server/src/routes/projects/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Projects routes - HTTP API for multi-project overview and management
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import type { FeatureLoader } from '../../services/feature-loader.js';
|
||||
import type { AutoModeService } from '../../services/auto-mode-service.js';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import type { NotificationService } from '../../services/notification-service.js';
|
||||
import { createOverviewHandler } from './routes/overview.js';
|
||||
|
||||
export function createProjectsRoutes(
|
||||
featureLoader: FeatureLoader,
|
||||
autoModeService: AutoModeService,
|
||||
settingsService: SettingsService,
|
||||
notificationService: NotificationService
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
// GET /overview - Get aggregate status for all projects
|
||||
router.get(
|
||||
'/overview',
|
||||
createOverviewHandler(featureLoader, autoModeService, settingsService, notificationService)
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
317
apps/server/src/routes/projects/routes/overview.ts
Normal file
317
apps/server/src/routes/projects/routes/overview.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* GET /overview endpoint - Get aggregate status for all projects
|
||||
*
|
||||
* Returns a complete overview of all projects including:
|
||||
* - Individual project status (features, auto-mode state)
|
||||
* - Aggregate metrics across all projects
|
||||
* - Recent activity feed (placeholder for future implementation)
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import type { NotificationService } from '../../../services/notification-service.js';
|
||||
import type {
|
||||
ProjectStatus,
|
||||
AggregateStatus,
|
||||
MultiProjectOverview,
|
||||
FeatureStatusCounts,
|
||||
AggregateFeatureCounts,
|
||||
AggregateProjectCounts,
|
||||
ProjectHealthStatus,
|
||||
Feature,
|
||||
ProjectRef,
|
||||
} from '@automaker/types';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
/**
|
||||
* Compute feature status counts from a list of features
|
||||
*/
|
||||
function computeFeatureCounts(features: Feature[]): FeatureStatusCounts {
|
||||
const counts: FeatureStatusCounts = {
|
||||
pending: 0,
|
||||
running: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
verified: 0,
|
||||
};
|
||||
|
||||
for (const feature of features) {
|
||||
switch (feature.status) {
|
||||
case 'pending':
|
||||
case 'ready':
|
||||
counts.pending++;
|
||||
break;
|
||||
case 'running':
|
||||
case 'generating_spec':
|
||||
case 'in_progress':
|
||||
counts.running++;
|
||||
break;
|
||||
case 'waiting_approval':
|
||||
// waiting_approval means agent finished, needs human review - count as pending
|
||||
counts.pending++;
|
||||
break;
|
||||
case 'completed':
|
||||
counts.completed++;
|
||||
break;
|
||||
case 'failed':
|
||||
counts.failed++;
|
||||
break;
|
||||
case 'verified':
|
||||
counts.verified++;
|
||||
break;
|
||||
default:
|
||||
// Unknown status, treat as pending
|
||||
counts.pending++;
|
||||
}
|
||||
}
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the overall health status of a project based on its feature statuses
|
||||
*/
|
||||
function computeHealthStatus(
|
||||
featureCounts: FeatureStatusCounts,
|
||||
isAutoModeRunning: boolean
|
||||
): ProjectHealthStatus {
|
||||
const totalFeatures =
|
||||
featureCounts.pending +
|
||||
featureCounts.running +
|
||||
featureCounts.completed +
|
||||
featureCounts.failed +
|
||||
featureCounts.verified;
|
||||
|
||||
// If there are failed features, the project has errors
|
||||
if (featureCounts.failed > 0) {
|
||||
return 'error';
|
||||
}
|
||||
|
||||
// If there are running features or auto mode is running with pending work
|
||||
if (featureCounts.running > 0 || (isAutoModeRunning && featureCounts.pending > 0)) {
|
||||
return 'active';
|
||||
}
|
||||
|
||||
// Pending work but no active execution
|
||||
if (featureCounts.pending > 0) {
|
||||
return 'waiting';
|
||||
}
|
||||
|
||||
// If all features are completed or verified
|
||||
if (totalFeatures > 0 && featureCounts.pending === 0 && featureCounts.running === 0) {
|
||||
return 'completed';
|
||||
}
|
||||
|
||||
// Default to idle
|
||||
return 'idle';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the most recent activity timestamp from features
|
||||
*/
|
||||
function getLastActivityAt(features: Feature[]): string | undefined {
|
||||
if (features.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let latestTimestamp: number = 0;
|
||||
|
||||
for (const feature of features) {
|
||||
// Check startedAt timestamp (the main timestamp available on Feature)
|
||||
if (feature.startedAt) {
|
||||
const timestamp = new Date(feature.startedAt).getTime();
|
||||
if (!isNaN(timestamp) && timestamp > latestTimestamp) {
|
||||
latestTimestamp = timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
// Also check planSpec timestamps if available
|
||||
if (feature.planSpec?.generatedAt) {
|
||||
const timestamp = new Date(feature.planSpec.generatedAt).getTime();
|
||||
if (!isNaN(timestamp) && timestamp > latestTimestamp) {
|
||||
latestTimestamp = timestamp;
|
||||
}
|
||||
}
|
||||
if (feature.planSpec?.approvedAt) {
|
||||
const timestamp = new Date(feature.planSpec.approvedAt).getTime();
|
||||
if (!isNaN(timestamp) && timestamp > latestTimestamp) {
|
||||
latestTimestamp = timestamp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return latestTimestamp > 0 ? new Date(latestTimestamp).toISOString() : undefined;
|
||||
}
|
||||
|
||||
export function createOverviewHandler(
|
||||
featureLoader: FeatureLoader,
|
||||
autoModeService: AutoModeService,
|
||||
settingsService: SettingsService,
|
||||
notificationService: NotificationService
|
||||
) {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Get all projects from settings
|
||||
const settings = await settingsService.getGlobalSettings();
|
||||
const projectRefs: ProjectRef[] = settings.projects || [];
|
||||
|
||||
// Get all running agents once to count live running features per project
|
||||
const allRunningAgents = await autoModeService.getRunningAgents();
|
||||
|
||||
// Collect project statuses in parallel
|
||||
const projectStatusPromises = projectRefs.map(async (projectRef): Promise<ProjectStatus> => {
|
||||
try {
|
||||
// Load features for this project
|
||||
const features = await featureLoader.getAll(projectRef.path);
|
||||
const featureCounts = computeFeatureCounts(features);
|
||||
const totalFeatures = features.length;
|
||||
|
||||
// Get auto-mode status for this project (main worktree, branchName = null)
|
||||
const autoModeStatus = autoModeService.getStatusForProject(projectRef.path, null);
|
||||
const isAutoModeRunning = autoModeStatus.isAutoLoopRunning;
|
||||
|
||||
// Count live running features for this project (across all branches)
|
||||
// This ensures we only count features that are actually running in memory
|
||||
const liveRunningCount = allRunningAgents.filter(
|
||||
(agent) => agent.projectPath === projectRef.path
|
||||
).length;
|
||||
featureCounts.running = liveRunningCount;
|
||||
|
||||
// Get notification count for this project
|
||||
let unreadNotificationCount = 0;
|
||||
try {
|
||||
const notifications = await notificationService.getNotifications(projectRef.path);
|
||||
unreadNotificationCount = notifications.filter((n) => !n.read).length;
|
||||
} catch {
|
||||
// Ignore notification errors - project may not have any notifications yet
|
||||
}
|
||||
|
||||
// Compute health status
|
||||
const healthStatus = computeHealthStatus(featureCounts, isAutoModeRunning);
|
||||
|
||||
// Get last activity timestamp
|
||||
const lastActivityAt = getLastActivityAt(features);
|
||||
|
||||
return {
|
||||
projectId: projectRef.id,
|
||||
projectName: projectRef.name,
|
||||
projectPath: projectRef.path,
|
||||
healthStatus,
|
||||
featureCounts,
|
||||
totalFeatures,
|
||||
lastActivityAt,
|
||||
isAutoModeRunning,
|
||||
activeBranch: autoModeStatus.branchName ?? undefined,
|
||||
unreadNotificationCount,
|
||||
};
|
||||
} catch (error) {
|
||||
logError(error, `Failed to load project status: ${projectRef.name}`);
|
||||
// Return a minimal status for projects that fail to load
|
||||
return {
|
||||
projectId: projectRef.id,
|
||||
projectName: projectRef.name,
|
||||
projectPath: projectRef.path,
|
||||
healthStatus: 'error' as ProjectHealthStatus,
|
||||
featureCounts: {
|
||||
pending: 0,
|
||||
running: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
verified: 0,
|
||||
},
|
||||
totalFeatures: 0,
|
||||
isAutoModeRunning: false,
|
||||
unreadNotificationCount: 0,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const projectStatuses = await Promise.all(projectStatusPromises);
|
||||
|
||||
// Compute aggregate metrics
|
||||
const aggregateFeatureCounts: AggregateFeatureCounts = {
|
||||
total: 0,
|
||||
pending: 0,
|
||||
running: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
verified: 0,
|
||||
};
|
||||
|
||||
const aggregateProjectCounts: AggregateProjectCounts = {
|
||||
total: projectStatuses.length,
|
||||
active: 0,
|
||||
idle: 0,
|
||||
waiting: 0,
|
||||
withErrors: 0,
|
||||
allCompleted: 0,
|
||||
};
|
||||
|
||||
let totalUnreadNotifications = 0;
|
||||
let projectsWithAutoModeRunning = 0;
|
||||
|
||||
for (const status of projectStatuses) {
|
||||
// Aggregate feature counts
|
||||
aggregateFeatureCounts.total += status.totalFeatures;
|
||||
aggregateFeatureCounts.pending += status.featureCounts.pending;
|
||||
aggregateFeatureCounts.running += status.featureCounts.running;
|
||||
aggregateFeatureCounts.completed += status.featureCounts.completed;
|
||||
aggregateFeatureCounts.failed += status.featureCounts.failed;
|
||||
aggregateFeatureCounts.verified += status.featureCounts.verified;
|
||||
|
||||
// Aggregate project counts by health status
|
||||
switch (status.healthStatus) {
|
||||
case 'active':
|
||||
aggregateProjectCounts.active++;
|
||||
break;
|
||||
case 'idle':
|
||||
aggregateProjectCounts.idle++;
|
||||
break;
|
||||
case 'waiting':
|
||||
aggregateProjectCounts.waiting++;
|
||||
break;
|
||||
case 'error':
|
||||
aggregateProjectCounts.withErrors++;
|
||||
break;
|
||||
case 'completed':
|
||||
aggregateProjectCounts.allCompleted++;
|
||||
break;
|
||||
}
|
||||
|
||||
// Aggregate notifications
|
||||
totalUnreadNotifications += status.unreadNotificationCount;
|
||||
|
||||
// Count projects with auto-mode running
|
||||
if (status.isAutoModeRunning) {
|
||||
projectsWithAutoModeRunning++;
|
||||
}
|
||||
}
|
||||
|
||||
const aggregateStatus: AggregateStatus = {
|
||||
projectCounts: aggregateProjectCounts,
|
||||
featureCounts: aggregateFeatureCounts,
|
||||
totalUnreadNotifications,
|
||||
projectsWithAutoModeRunning,
|
||||
computedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Build the response (recentActivity is empty for now - can be populated later)
|
||||
const overview: MultiProjectOverview = {
|
||||
projects: projectStatuses,
|
||||
aggregate: aggregateStatus,
|
||||
recentActivity: [], // Placeholder for future activity feed implementation
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
...overview,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Get project overview failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -4,12 +4,58 @@
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import { getBacklogPlanStatus, getRunningDetails } from '../../backlog-plan/common.js';
|
||||
import { getAllRunningGenerations } from '../../app-spec/common.js';
|
||||
import path from 'path';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createIndexHandler(autoModeService: AutoModeService) {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const runningAgents = await autoModeService.getRunningAgents();
|
||||
const runningAgents = [...(await autoModeService.getRunningAgents())];
|
||||
const backlogPlanStatus = getBacklogPlanStatus();
|
||||
const backlogPlanDetails = getRunningDetails();
|
||||
|
||||
if (backlogPlanStatus.isRunning && backlogPlanDetails) {
|
||||
runningAgents.push({
|
||||
featureId: `backlog-plan:${backlogPlanDetails.projectPath}`,
|
||||
projectPath: backlogPlanDetails.projectPath,
|
||||
projectName: path.basename(backlogPlanDetails.projectPath),
|
||||
isAutoMode: false,
|
||||
title: 'Backlog plan',
|
||||
description: backlogPlanDetails.prompt,
|
||||
});
|
||||
}
|
||||
|
||||
// Add spec/feature generation tasks
|
||||
const specGenerations = getAllRunningGenerations();
|
||||
for (const generation of specGenerations) {
|
||||
let title: string;
|
||||
let description: string;
|
||||
|
||||
switch (generation.type) {
|
||||
case 'feature_generation':
|
||||
title = 'Generating features from spec';
|
||||
description = 'Creating features from the project specification';
|
||||
break;
|
||||
case 'sync':
|
||||
title = 'Syncing spec with code';
|
||||
description = 'Updating spec from codebase and completed features';
|
||||
break;
|
||||
default:
|
||||
title = 'Regenerating spec';
|
||||
description = 'Analyzing project and generating specification';
|
||||
}
|
||||
|
||||
runningAgents.push({
|
||||
featureId: `spec-generation:${generation.projectPath}`,
|
||||
projectPath: generation.projectPath,
|
||||
projectName: path.basename(generation.projectPath),
|
||||
isAutoMode: false,
|
||||
title,
|
||||
description,
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
|
||||
@@ -12,6 +12,18 @@ import type { Request, Response } from 'express';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import type { GlobalSettings } from '../../../types/settings.js';
|
||||
import { getErrorMessage, logError, logger } from '../common.js';
|
||||
import { setLogLevel, LogLevel } from '@automaker/utils';
|
||||
import { setRequestLoggingEnabled } from '../../../index.js';
|
||||
|
||||
/**
|
||||
* Map server log level string to LogLevel enum
|
||||
*/
|
||||
const LOG_LEVEL_MAP: Record<string, LogLevel> = {
|
||||
error: LogLevel.ERROR,
|
||||
warn: LogLevel.WARN,
|
||||
info: LogLevel.INFO,
|
||||
debug: LogLevel.DEBUG,
|
||||
};
|
||||
|
||||
/**
|
||||
* Create handler factory for PUT /api/settings/global
|
||||
@@ -33,18 +45,41 @@ export function createUpdateGlobalHandler(settingsService: SettingsService) {
|
||||
}
|
||||
|
||||
// Minimal debug logging to help diagnose accidental wipes.
|
||||
if ('projects' in updates || 'theme' in updates || 'localStorageMigrated' in updates) {
|
||||
const projectsLen = Array.isArray((updates as any).projects)
|
||||
? (updates as any).projects.length
|
||||
: undefined;
|
||||
logger.info(
|
||||
`Update global settings request: projects=${projectsLen ?? 'n/a'}, theme=${
|
||||
(updates as any).theme ?? 'n/a'
|
||||
}, localStorageMigrated=${(updates as any).localStorageMigrated ?? 'n/a'}`
|
||||
);
|
||||
const projectsLen = Array.isArray((updates as any).projects)
|
||||
? (updates as any).projects.length
|
||||
: undefined;
|
||||
const trashedLen = Array.isArray((updates as any).trashedProjects)
|
||||
? (updates as any).trashedProjects.length
|
||||
: undefined;
|
||||
logger.info(
|
||||
`[SERVER_SETTINGS_UPDATE] Request received: projects=${projectsLen ?? 'n/a'}, trashedProjects=${trashedLen ?? 'n/a'}, theme=${
|
||||
(updates as any).theme ?? 'n/a'
|
||||
}, localStorageMigrated=${(updates as any).localStorageMigrated ?? 'n/a'}`
|
||||
);
|
||||
|
||||
logger.info('[SERVER_SETTINGS_UPDATE] Calling updateGlobalSettings...');
|
||||
const settings = await settingsService.updateGlobalSettings(updates);
|
||||
logger.info(
|
||||
'[SERVER_SETTINGS_UPDATE] Update complete, projects count:',
|
||||
settings.projects?.length ?? 0
|
||||
);
|
||||
|
||||
// Apply server log level if it was updated
|
||||
if ('serverLogLevel' in updates && updates.serverLogLevel) {
|
||||
const level = LOG_LEVEL_MAP[updates.serverLogLevel];
|
||||
if (level !== undefined) {
|
||||
setLogLevel(level);
|
||||
logger.info(`Server log level changed to: ${updates.serverLogLevel}`);
|
||||
}
|
||||
}
|
||||
|
||||
const settings = await settingsService.updateGlobalSettings(updates);
|
||||
// Apply request logging setting if it was updated
|
||||
if ('enableRequestLogging' in updates && typeof updates.enableRequestLogging === 'boolean') {
|
||||
setRequestLoggingEnabled(updates.enableRequestLogging);
|
||||
logger.info(
|
||||
`HTTP request logging ${updates.enableRequestLogging ? 'enabled' : 'disabled'}`
|
||||
);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
|
||||
@@ -52,3 +52,8 @@ export async function persistApiKeyToEnv(key: string, value: string): Promise<vo
|
||||
// Re-export shared utilities
|
||||
export { getErrorMessageShared as getErrorMessage };
|
||||
export const logError = createLogError(logger);
|
||||
|
||||
/**
|
||||
* Marker file used to indicate a provider has been explicitly disconnected by user
|
||||
*/
|
||||
export const COPILOT_DISCONNECTED_MARKER_FILE = '.copilot-disconnected';
|
||||
|
||||
@@ -24,6 +24,17 @@ import { createDeauthCursorHandler } from './routes/deauth-cursor.js';
|
||||
import { createAuthOpencodeHandler } from './routes/auth-opencode.js';
|
||||
import { createDeauthOpencodeHandler } from './routes/deauth-opencode.js';
|
||||
import { createOpencodeStatusHandler } from './routes/opencode-status.js';
|
||||
import { createGeminiStatusHandler } from './routes/gemini-status.js';
|
||||
import { createAuthGeminiHandler } from './routes/auth-gemini.js';
|
||||
import { createDeauthGeminiHandler } from './routes/deauth-gemini.js';
|
||||
import { createCopilotStatusHandler } from './routes/copilot-status.js';
|
||||
import { createAuthCopilotHandler } from './routes/auth-copilot.js';
|
||||
import { createDeauthCopilotHandler } from './routes/deauth-copilot.js';
|
||||
import {
|
||||
createGetCopilotModelsHandler,
|
||||
createRefreshCopilotModelsHandler,
|
||||
createClearCopilotCacheHandler,
|
||||
} from './routes/copilot-models.js';
|
||||
import {
|
||||
createGetOpencodeModelsHandler,
|
||||
createRefreshOpencodeModelsHandler,
|
||||
@@ -72,6 +83,21 @@ export function createSetupRoutes(): Router {
|
||||
router.post('/auth-opencode', createAuthOpencodeHandler());
|
||||
router.post('/deauth-opencode', createDeauthOpencodeHandler());
|
||||
|
||||
// Gemini CLI routes
|
||||
router.get('/gemini-status', createGeminiStatusHandler());
|
||||
router.post('/auth-gemini', createAuthGeminiHandler());
|
||||
router.post('/deauth-gemini', createDeauthGeminiHandler());
|
||||
|
||||
// Copilot CLI routes
|
||||
router.get('/copilot-status', createCopilotStatusHandler());
|
||||
router.post('/auth-copilot', createAuthCopilotHandler());
|
||||
router.post('/deauth-copilot', createDeauthCopilotHandler());
|
||||
|
||||
// Copilot Dynamic Model Discovery routes
|
||||
router.get('/copilot/models', createGetCopilotModelsHandler());
|
||||
router.post('/copilot/models/refresh', createRefreshCopilotModelsHandler());
|
||||
router.post('/copilot/cache/clear', createClearCopilotCacheHandler());
|
||||
|
||||
// OpenCode Dynamic Model Discovery routes
|
||||
router.get('/opencode/models', createGetOpencodeModelsHandler());
|
||||
router.post('/opencode/models/refresh', createRefreshOpencodeModelsHandler());
|
||||
|
||||
30
apps/server/src/routes/setup/routes/auth-copilot.ts
Normal file
30
apps/server/src/routes/setup/routes/auth-copilot.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* POST /auth-copilot endpoint - Connect Copilot CLI to the app
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { connectCopilot } from '../../../services/copilot-connection-service.js';
|
||||
|
||||
/**
|
||||
* Creates handler for POST /api/setup/auth-copilot
|
||||
* Removes the disconnection marker to allow Copilot CLI to be used
|
||||
*/
|
||||
export function createAuthCopilotHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
await connectCopilot();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Copilot CLI connected to app',
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Auth Copilot failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
42
apps/server/src/routes/setup/routes/auth-gemini.ts
Normal file
42
apps/server/src/routes/setup/routes/auth-gemini.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* POST /auth-gemini endpoint - Connect Gemini CLI to the app
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
const DISCONNECTED_MARKER_FILE = '.gemini-disconnected';
|
||||
|
||||
/**
|
||||
* Creates handler for POST /api/setup/auth-gemini
|
||||
* Removes the disconnection marker to allow Gemini CLI to be used
|
||||
*/
|
||||
export function createAuthGeminiHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const projectRoot = process.cwd();
|
||||
const automakerDir = path.join(projectRoot, '.automaker');
|
||||
const markerPath = path.join(automakerDir, DISCONNECTED_MARKER_FILE);
|
||||
|
||||
// Remove the disconnection marker if it exists
|
||||
try {
|
||||
await fs.unlink(markerPath);
|
||||
} catch {
|
||||
// File doesn't exist, nothing to remove
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Gemini CLI connected to app',
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Auth Gemini failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
139
apps/server/src/routes/setup/routes/copilot-models.ts
Normal file
139
apps/server/src/routes/setup/routes/copilot-models.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Copilot Dynamic Models API Routes
|
||||
*
|
||||
* Provides endpoints for:
|
||||
* - GET /api/setup/copilot/models - Get available models (cached or refreshed)
|
||||
* - POST /api/setup/copilot/models/refresh - Force refresh models from CLI
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { CopilotProvider } from '../../../providers/copilot-provider.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import type { ModelDefinition } from '@automaker/types';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('CopilotModelsRoute');
|
||||
|
||||
// Singleton provider instance for caching
|
||||
let providerInstance: CopilotProvider | null = null;
|
||||
|
||||
function getProvider(): CopilotProvider {
|
||||
if (!providerInstance) {
|
||||
providerInstance = new CopilotProvider();
|
||||
}
|
||||
return providerInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response type for models endpoint
|
||||
*/
|
||||
interface ModelsResponse {
|
||||
success: boolean;
|
||||
models?: ModelDefinition[];
|
||||
count?: number;
|
||||
cached?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates handler for GET /api/setup/copilot/models
|
||||
*
|
||||
* Returns currently available models (from cache if available).
|
||||
* Query params:
|
||||
* - refresh=true: Force refresh from CLI before returning
|
||||
*
|
||||
* Note: If cache is empty, this will trigger a refresh to get dynamic models.
|
||||
*/
|
||||
export function createGetCopilotModelsHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const provider = getProvider();
|
||||
const forceRefresh = req.query.refresh === 'true';
|
||||
|
||||
let models: ModelDefinition[];
|
||||
let cached = true;
|
||||
|
||||
if (forceRefresh) {
|
||||
models = await provider.refreshModels();
|
||||
cached = false;
|
||||
} else {
|
||||
// Check if we have cached models
|
||||
if (!provider.hasCachedModels()) {
|
||||
models = await provider.refreshModels();
|
||||
cached = false;
|
||||
} else {
|
||||
models = provider.getAvailableModels();
|
||||
}
|
||||
}
|
||||
|
||||
const response: ModelsResponse = {
|
||||
success: true,
|
||||
models,
|
||||
count: models.length,
|
||||
cached,
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
logError(error, 'Get Copilot models failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
} as ModelsResponse);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates handler for POST /api/setup/copilot/models/refresh
|
||||
*
|
||||
* Forces a refresh of models from the Copilot CLI.
|
||||
*/
|
||||
export function createRefreshCopilotModelsHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const provider = getProvider();
|
||||
const models = await provider.refreshModels();
|
||||
|
||||
const response: ModelsResponse = {
|
||||
success: true,
|
||||
models,
|
||||
count: models.length,
|
||||
cached: false,
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
logError(error, 'Refresh Copilot models failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
} as ModelsResponse);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates handler for POST /api/setup/copilot/cache/clear
|
||||
*
|
||||
* Clears the model cache, forcing a fresh fetch on next access.
|
||||
*/
|
||||
export function createClearCopilotCacheHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const provider = getProvider();
|
||||
provider.clearModelCache();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Copilot model cache cleared',
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Clear Copilot cache failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
78
apps/server/src/routes/setup/routes/copilot-status.ts
Normal file
78
apps/server/src/routes/setup/routes/copilot-status.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* GET /copilot-status endpoint - Get Copilot CLI installation and auth status
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { CopilotProvider } from '../../../providers/copilot-provider.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
const DISCONNECTED_MARKER_FILE = '.copilot-disconnected';
|
||||
|
||||
async function isCopilotDisconnectedFromApp(): Promise<boolean> {
|
||||
try {
|
||||
const projectRoot = process.cwd();
|
||||
const markerPath = path.join(projectRoot, '.automaker', DISCONNECTED_MARKER_FILE);
|
||||
await fs.access(markerPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates handler for GET /api/setup/copilot-status
|
||||
* Returns Copilot CLI installation and authentication status
|
||||
*/
|
||||
export function createCopilotStatusHandler() {
|
||||
const installCommand = 'npm install -g @github/copilot';
|
||||
const loginCommand = 'gh auth login';
|
||||
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Check if user has manually disconnected from the app
|
||||
if (await isCopilotDisconnectedFromApp()) {
|
||||
res.json({
|
||||
success: true,
|
||||
installed: true,
|
||||
version: null,
|
||||
path: null,
|
||||
auth: {
|
||||
authenticated: false,
|
||||
method: 'none',
|
||||
},
|
||||
installCommand,
|
||||
loginCommand,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const provider = new CopilotProvider();
|
||||
const status = await provider.detectInstallation();
|
||||
const auth = await provider.checkAuth();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
installed: status.installed,
|
||||
version: status.version || null,
|
||||
path: status.path || null,
|
||||
auth: {
|
||||
authenticated: auth.authenticated,
|
||||
method: auth.method,
|
||||
login: auth.login,
|
||||
host: auth.host,
|
||||
error: auth.error,
|
||||
},
|
||||
installCommand,
|
||||
loginCommand,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Get Copilot status failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
30
apps/server/src/routes/setup/routes/deauth-copilot.ts
Normal file
30
apps/server/src/routes/setup/routes/deauth-copilot.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* POST /deauth-copilot endpoint - Disconnect Copilot CLI from the app
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { disconnectCopilot } from '../../../services/copilot-connection-service.js';
|
||||
|
||||
/**
|
||||
* Creates handler for POST /api/setup/deauth-copilot
|
||||
* Creates a marker file to disconnect Copilot CLI from the app
|
||||
*/
|
||||
export function createDeauthCopilotHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
await disconnectCopilot();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Copilot CLI disconnected from app',
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Deauth Copilot failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
42
apps/server/src/routes/setup/routes/deauth-gemini.ts
Normal file
42
apps/server/src/routes/setup/routes/deauth-gemini.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* POST /deauth-gemini endpoint - Disconnect Gemini CLI from the app
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
const DISCONNECTED_MARKER_FILE = '.gemini-disconnected';
|
||||
|
||||
/**
|
||||
* Creates handler for POST /api/setup/deauth-gemini
|
||||
* Creates a marker file to disconnect Gemini CLI from the app
|
||||
*/
|
||||
export function createDeauthGeminiHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const projectRoot = process.cwd();
|
||||
const automakerDir = path.join(projectRoot, '.automaker');
|
||||
|
||||
// Ensure .automaker directory exists
|
||||
await fs.mkdir(automakerDir, { recursive: true });
|
||||
|
||||
const markerPath = path.join(automakerDir, DISCONNECTED_MARKER_FILE);
|
||||
|
||||
// Create the disconnection marker
|
||||
await fs.writeFile(markerPath, 'Gemini CLI disconnected from app');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Gemini CLI disconnected from app',
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Deauth Gemini failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
79
apps/server/src/routes/setup/routes/gemini-status.ts
Normal file
79
apps/server/src/routes/setup/routes/gemini-status.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* GET /gemini-status endpoint - Get Gemini CLI installation and auth status
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { GeminiProvider } from '../../../providers/gemini-provider.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
const DISCONNECTED_MARKER_FILE = '.gemini-disconnected';
|
||||
|
||||
async function isGeminiDisconnectedFromApp(): Promise<boolean> {
|
||||
try {
|
||||
const projectRoot = process.cwd();
|
||||
const markerPath = path.join(projectRoot, '.automaker', DISCONNECTED_MARKER_FILE);
|
||||
await fs.access(markerPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates handler for GET /api/setup/gemini-status
|
||||
* Returns Gemini CLI installation and authentication status
|
||||
*/
|
||||
export function createGeminiStatusHandler() {
|
||||
const installCommand = 'npm install -g @google/gemini-cli';
|
||||
const loginCommand = 'gemini';
|
||||
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Check if user has manually disconnected from the app
|
||||
if (await isGeminiDisconnectedFromApp()) {
|
||||
res.json({
|
||||
success: true,
|
||||
installed: true,
|
||||
version: null,
|
||||
path: null,
|
||||
auth: {
|
||||
authenticated: false,
|
||||
method: 'none',
|
||||
hasApiKey: false,
|
||||
},
|
||||
installCommand,
|
||||
loginCommand,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const provider = new GeminiProvider();
|
||||
const status = await provider.detectInstallation();
|
||||
const auth = await provider.checkAuth();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
installed: status.installed,
|
||||
version: status.version || null,
|
||||
path: status.path || null,
|
||||
auth: {
|
||||
authenticated: auth.authenticated,
|
||||
method: auth.method,
|
||||
hasApiKey: auth.hasApiKey || false,
|
||||
hasEnvApiKey: auth.hasEnvApiKey || false,
|
||||
error: auth.error,
|
||||
},
|
||||
installCommand,
|
||||
loginCommand,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Get Gemini status failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* Common utilities and state for suggestions routes
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
|
||||
|
||||
const logger = createLogger('Suggestions');
|
||||
|
||||
// Shared state for tracking generation status - private
|
||||
let isRunning = false;
|
||||
let currentAbortController: AbortController | null = null;
|
||||
|
||||
/**
|
||||
* Get the current running state
|
||||
*/
|
||||
export function getSuggestionsStatus(): {
|
||||
isRunning: boolean;
|
||||
currentAbortController: AbortController | null;
|
||||
} {
|
||||
return { isRunning, currentAbortController };
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the running state and abort controller
|
||||
*/
|
||||
export function setRunningState(running: boolean, controller: AbortController | null = null): void {
|
||||
isRunning = running;
|
||||
currentAbortController = controller;
|
||||
}
|
||||
|
||||
// Re-export shared utilities
|
||||
export { getErrorMessageShared as getErrorMessage };
|
||||
export const logError = createLogError(logger);
|
||||
@@ -1,300 +0,0 @@
|
||||
/**
|
||||
* 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 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 { extractJsonWithArray } from '../../lib/json-extractor.js';
|
||||
import { streamingQuery } from '../../providers/simple-query-service.js';
|
||||
import { FeatureLoader } from '../../services/feature-loader.js';
|
||||
import { getAppSpecPath } from '@automaker/platform';
|
||||
import * as secureFs from '../../lib/secure-fs.js';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import { getAutoLoadClaudeMdSetting } from '../../lib/settings-helpers.js';
|
||||
|
||||
const logger = createLogger('Suggestions');
|
||||
|
||||
/**
|
||||
* Extract implemented features from app_spec.txt XML content
|
||||
*
|
||||
* Note: This uses regex-based parsing which is sufficient for our controlled
|
||||
* XML structure. If more complex XML parsing is needed in the future, consider
|
||||
* using a library like 'fast-xml-parser' or 'xml2js'.
|
||||
*/
|
||||
function extractImplementedFeatures(specContent: string): string[] {
|
||||
const features: string[] = [];
|
||||
|
||||
// Match <implemented_features>...</implemented_features> section
|
||||
const implementedMatch = specContent.match(
|
||||
/<implemented_features>([\s\S]*?)<\/implemented_features>/
|
||||
);
|
||||
|
||||
if (implementedMatch) {
|
||||
const implementedSection = implementedMatch[1];
|
||||
|
||||
// Extract feature names from <name>...</name> tags using matchAll
|
||||
const nameRegex = /<name>(.*?)<\/name>/g;
|
||||
const matches = implementedSection.matchAll(nameRegex);
|
||||
|
||||
for (const match of matches) {
|
||||
features.push(match[1].trim());
|
||||
}
|
||||
}
|
||||
|
||||
return features;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load existing context (app spec and backlog features) to avoid duplicates
|
||||
*/
|
||||
async function loadExistingContext(projectPath: string): Promise<string> {
|
||||
let context = '';
|
||||
|
||||
// 1. Read app_spec.txt for implemented features
|
||||
try {
|
||||
const appSpecPath = getAppSpecPath(projectPath);
|
||||
const specContent = (await secureFs.readFile(appSpecPath, 'utf-8')) as string;
|
||||
|
||||
if (specContent && specContent.trim().length > 0) {
|
||||
const implementedFeatures = extractImplementedFeatures(specContent);
|
||||
|
||||
if (implementedFeatures.length > 0) {
|
||||
context += '\n\n=== ALREADY IMPLEMENTED FEATURES ===\n';
|
||||
context += 'These features are already implemented in the codebase:\n';
|
||||
context += implementedFeatures.map((feature) => `- ${feature}`).join('\n') + '\n';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// app_spec.txt doesn't exist or can't be read - that's okay
|
||||
logger.debug('No app_spec.txt found or error reading it:', error);
|
||||
}
|
||||
|
||||
// 2. Load existing features from backlog
|
||||
try {
|
||||
const featureLoader = new FeatureLoader();
|
||||
const features = await featureLoader.getAll(projectPath);
|
||||
|
||||
if (features.length > 0) {
|
||||
context += '\n\n=== EXISTING FEATURES IN BACKLOG ===\n';
|
||||
context += 'These features are already planned or in progress:\n';
|
||||
context +=
|
||||
features
|
||||
.map((feature) => {
|
||||
const status = feature.status || 'pending';
|
||||
const title = feature.title || feature.description?.substring(0, 50) || 'Untitled';
|
||||
return `- ${title} (${status})`;
|
||||
})
|
||||
.join('\n') + '\n';
|
||||
}
|
||||
} catch (error) {
|
||||
// Features directory doesn't exist or can't be read - that's okay
|
||||
logger.debug('No features found or error loading them:', error);
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON Schema for suggestions output
|
||||
*/
|
||||
const suggestionsSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
suggestions: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
category: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
priority: {
|
||||
type: 'number',
|
||||
minimum: 1,
|
||||
maximum: 3,
|
||||
},
|
||||
reasoning: { type: 'string' },
|
||||
},
|
||||
required: ['category', 'description', 'priority', 'reasoning'],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['suggestions'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
export async function generateSuggestions(
|
||||
projectPath: string,
|
||||
suggestionType: string,
|
||||
events: EventEmitter,
|
||||
abortController: AbortController,
|
||||
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.',
|
||||
refactoring: 'Analyze this project and identify refactoring opportunities.',
|
||||
security: 'Analyze this project for security vulnerabilities and suggest fixes.',
|
||||
performance: 'Analyze this project for performance issues and suggest optimizations.',
|
||||
};
|
||||
|
||||
// Load existing context to avoid duplicates
|
||||
const existingContext = await loadExistingContext(projectPath);
|
||||
|
||||
const prompt = `${typePrompts[suggestionType] || typePrompts.features}
|
||||
${existingContext}
|
||||
|
||||
${existingContext ? '\nIMPORTANT: Do NOT suggest features that are already implemented or already in the backlog above. Focus on NEW ideas that complement what already exists.\n' : ''}
|
||||
Look at the codebase and provide 3-5 concrete suggestions.
|
||||
|
||||
For each suggestion, provide:
|
||||
1. A category (e.g., "User Experience", "Security", "Performance")
|
||||
2. A clear description of what to implement
|
||||
3. Priority (1=high, 2=medium, 3=low)
|
||||
4. Brief reasoning for why this would help
|
||||
|
||||
The response will be automatically formatted as structured JSON.`;
|
||||
|
||||
// Don't send initial message - let the agent output speak for itself
|
||||
// The first agent message will be captured as an info entry
|
||||
|
||||
// Load autoLoadClaudeMd setting
|
||||
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
|
||||
projectPath,
|
||||
settingsService,
|
||||
'[Suggestions]'
|
||||
);
|
||||
|
||||
// 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);
|
||||
|
||||
let responseText = '';
|
||||
|
||||
// Determine if we should use structured output (Claude supports it, Cursor doesn't)
|
||||
const useStructuredOutput = !isCursorModel(model);
|
||||
|
||||
// Build the final prompt - for Cursor, include JSON schema instructions
|
||||
let finalPrompt = prompt;
|
||||
if (!useStructuredOutput) {
|
||||
finalPrompt = `${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.`;
|
||||
}
|
||||
|
||||
// Use streamingQuery with event callbacks
|
||||
const result = await streamingQuery({
|
||||
prompt: finalPrompt,
|
||||
model,
|
||||
cwd: projectPath,
|
||||
maxTurns: 250,
|
||||
allowedTools: ['Read', 'Glob', 'Grep'],
|
||||
abortController,
|
||||
thinkingLevel,
|
||||
readOnly: true, // Suggestions only reads code, doesn't write
|
||||
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
|
||||
outputFormat: useStructuredOutput
|
||||
? {
|
||||
type: 'json_schema',
|
||||
schema: suggestionsSchema,
|
||||
}
|
||||
: undefined,
|
||||
onText: (text) => {
|
||||
responseText += text;
|
||||
events.emit('suggestions:event', {
|
||||
type: 'suggestions_progress',
|
||||
content: text,
|
||||
});
|
||||
},
|
||||
onToolUse: (tool, input) => {
|
||||
events.emit('suggestions:event', {
|
||||
type: 'suggestions_tool',
|
||||
tool,
|
||||
input,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Use structured output if available, otherwise fall back to parsing text
|
||||
try {
|
||||
let structuredOutput: { suggestions: Array<Record<string, unknown>> } | null = null;
|
||||
|
||||
if (result.structured_output) {
|
||||
structuredOutput = result.structured_output as {
|
||||
suggestions: Array<Record<string, unknown>>;
|
||||
};
|
||||
logger.debug('Received structured output:', structuredOutput);
|
||||
} else if (responseText) {
|
||||
// Fallback: try to parse from text using shared extraction utility
|
||||
logger.warn('No structured output received, attempting to parse from text');
|
||||
structuredOutput = extractJsonWithArray<{ suggestions: Array<Record<string, unknown>> }>(
|
||||
responseText,
|
||||
'suggestions',
|
||||
{ logger }
|
||||
);
|
||||
}
|
||||
|
||||
if (structuredOutput && structuredOutput.suggestions) {
|
||||
// Use structured output directly
|
||||
events.emit('suggestions:event', {
|
||||
type: 'suggestions_complete',
|
||||
suggestions: structuredOutput.suggestions.map((s: Record<string, unknown>, i: number) => ({
|
||||
...s,
|
||||
id: s.id || `suggestion-${Date.now()}-${i}`,
|
||||
})),
|
||||
});
|
||||
} else {
|
||||
throw new Error('No valid JSON found in response');
|
||||
}
|
||||
} catch (error) {
|
||||
// Log the parsing error for debugging
|
||||
logger.error('Failed to parse suggestions JSON from AI response:', error);
|
||||
// Return generic suggestions if parsing fails
|
||||
events.emit('suggestions:event', {
|
||||
type: 'suggestions_complete',
|
||||
suggestions: [
|
||||
{
|
||||
id: `suggestion-${Date.now()}-0`,
|
||||
category: 'Analysis',
|
||||
description: 'Review the AI analysis output for insights',
|
||||
priority: 1,
|
||||
reasoning: 'The AI provided analysis but suggestions need manual review',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* Suggestions routes - HTTP API for AI-powered feature suggestions
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import { validatePathParams } from '../../middleware/validate-paths.js';
|
||||
import { createGenerateHandler } from './routes/generate.js';
|
||||
import { createStopHandler } from './routes/stop.js';
|
||||
import { createStatusHandler } from './routes/status.js';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
|
||||
export function createSuggestionsRoutes(
|
||||
events: EventEmitter,
|
||||
settingsService?: SettingsService
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
router.post(
|
||||
'/generate',
|
||||
validatePathParams('projectPath'),
|
||||
createGenerateHandler(events, settingsService)
|
||||
);
|
||||
router.post('/stop', createStopHandler());
|
||||
router.get('/status', createStatusHandler());
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* POST /generate endpoint - Generate suggestions
|
||||
*/
|
||||
|
||||
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';
|
||||
|
||||
const logger = createLogger('Suggestions');
|
||||
|
||||
export function createGenerateHandler(events: EventEmitter, settingsService?: SettingsService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const {
|
||||
projectPath,
|
||||
suggestionType = 'features',
|
||||
model,
|
||||
thinkingLevel,
|
||||
} = req.body as {
|
||||
projectPath: string;
|
||||
suggestionType?: string;
|
||||
model?: string;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { isRunning } = getSuggestionsStatus();
|
||||
if (isRunning) {
|
||||
res.json({
|
||||
success: false,
|
||||
error: 'Suggestions generation is already running',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setRunningState(true);
|
||||
const abortController = new AbortController();
|
||||
setRunningState(true, abortController);
|
||||
|
||||
// Start generation in background
|
||||
generateSuggestions(
|
||||
projectPath,
|
||||
suggestionType,
|
||||
events,
|
||||
abortController,
|
||||
settingsService,
|
||||
model,
|
||||
thinkingLevel
|
||||
)
|
||||
.catch((error) => {
|
||||
logError(error, 'Generate suggestions failed (background)');
|
||||
events.emit('suggestions:event', {
|
||||
type: 'suggestions_error',
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setRunningState(false, null);
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logError(error, 'Generate suggestions failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* GET /status endpoint - Get status
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getSuggestionsStatus, getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createStatusHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { isRunning } = getSuggestionsStatus();
|
||||
res.json({ success: true, isRunning });
|
||||
} catch (error) {
|
||||
logError(error, 'Get status failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
/**
|
||||
* POST /stop endpoint - Stop suggestions generation
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getSuggestionsStatus, setRunningState, getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createStopHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { currentAbortController } = getSuggestionsStatus();
|
||||
if (currentAbortController) {
|
||||
currentAbortController.abort();
|
||||
}
|
||||
setRunningState(false, null);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logError(error, 'Stop suggestions failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user