mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 14:22:02 +00:00
Compare commits
344 Commits
v0.3.5
...
feature/to
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3559e0104c | ||
|
|
d104a24446 | ||
|
|
a26ef4347a | ||
|
|
e9dba8c9e5 | ||
|
|
2b02db8ae3 | ||
|
|
1ad3b1739b | ||
|
|
17a2053e79 | ||
|
|
46c3dd252f | ||
|
|
96b0e74794 | ||
|
|
18a2ed2a44 | ||
|
|
7d6ed0cb37 | ||
|
|
396100686c | ||
|
|
35ecb0dd2d | ||
|
|
8d6dae7495 | ||
|
|
340e76c3ed | ||
|
|
275037c73d | ||
|
|
a14ef30c69 | ||
|
|
18fa0f3066 | ||
|
|
3e015591d3 | ||
|
|
81d631ea72 | ||
|
|
2a1ab218ec | ||
|
|
6c31f725ff | ||
|
|
f0bea76141 | ||
|
|
46933a2a81 | ||
|
|
dccb5faa4b | ||
|
|
15ae1fe147 | ||
|
|
6f82f64195 | ||
|
|
1656b4fb7a | ||
|
|
419e954230 | ||
|
|
1a83c9b256 | ||
|
|
a0efa5d351 | ||
|
|
afcda98dc4 | ||
|
|
e508f9c1d1 | ||
|
|
8c2c54b0a4 | ||
|
|
65edddbc36 | ||
|
|
0dfe5a91e7 | ||
|
|
2f75c0bec5 | ||
|
|
516f26edae | ||
|
|
dd8862ce21 | ||
|
|
0c2192d039 | ||
|
|
a85390b289 | ||
|
|
c4a90d7f29 | ||
|
|
8794156f28 | ||
|
|
7603c827f6 | ||
|
|
7ad70a3923 | ||
|
|
95c6a69610 | ||
|
|
157dd71efa | ||
|
|
06ed965a3b | ||
|
|
ea7e273fb4 | ||
|
|
dcf05e4f1c | ||
|
|
50fb7ece4f | ||
|
|
f9db4fffa7 | ||
|
|
1cb6daaa07 | ||
|
|
adf9307796 | ||
|
|
7fdc2b2fab | ||
|
|
0d8043f1f2 | ||
|
|
2c079623a8 | ||
|
|
899c45fc1b | ||
|
|
c763f2a545 | ||
|
|
45dd1d45a1 | ||
|
|
a860b3cf45 | ||
|
|
9fcdd899b2 | ||
|
|
bacafd129f | ||
|
|
20d7fb1949 | ||
|
|
a192eaa20f | ||
|
|
99fe6f6497 | ||
|
|
35c6beca37 | ||
|
|
f7cb92fa9d | ||
|
|
b403d0d570 | ||
|
|
c80ae3367a | ||
|
|
c7312af3c8 | ||
|
|
f3dbc996d4 | ||
|
|
0549b8085a | ||
|
|
760f254f78 | ||
|
|
91bff6c572 | ||
|
|
019ac56ceb | ||
|
|
ecf34b178e | ||
|
|
3cd6e8c13b | ||
|
|
ca8341bf39 | ||
|
|
160bd8bfc7 | ||
|
|
0d1138dfcf | ||
|
|
b112747073 | ||
|
|
e1c3b7528f | ||
|
|
e78bfc80ec | ||
|
|
3eac848d4f | ||
|
|
76cb72812f | ||
|
|
bfc8f9bc26 | ||
|
|
8f2e06bc32 | ||
|
|
2c9f77356f | ||
|
|
ea1b10fea6 | ||
|
|
e6a63ccae1 | ||
|
|
784188cf52 | ||
|
|
1d945f4f75 | ||
|
|
df50cccb7b | ||
|
|
b0a9c89157 | ||
|
|
45eaf91cb3 | ||
|
|
c20b224f30 | ||
|
|
96b941b008 | ||
|
|
ad4da23743 | ||
|
|
5136c32b68 | ||
|
|
cffdec91f1 | ||
|
|
d9c87f8116 | ||
|
|
9954feafd8 | ||
|
|
acfb8d2255 | ||
|
|
fe6106e807 | ||
|
|
2af43d7c2d | ||
|
|
a7a6ff2e6c | ||
|
|
8f598d7ce3 | ||
|
|
7f8092264a | ||
|
|
e0471fef09 | ||
|
|
043edde63b | ||
|
|
4b9a211c49 | ||
|
|
b59bbd93ba | ||
|
|
2a782392bc | ||
|
|
fe56ba133e | ||
|
|
40a3046a3b | ||
|
|
1aa8b5b56b | ||
|
|
266e0c54b9 | ||
|
|
31550ab4e7 | ||
|
|
cce8d1569a | ||
|
|
ad051eb8f0 | ||
|
|
01098545cf | ||
|
|
d58bd782ef | ||
|
|
c11cb6a6cd | ||
|
|
64549f824c | ||
|
|
83fab5321e | ||
|
|
bb47f22d6c | ||
|
|
4996a63bcc | ||
|
|
f302234b0e | ||
|
|
58d6ae02a5 | ||
|
|
f9ec7222f2 | ||
|
|
360b7ebe08 | ||
|
|
ebc99d06eb | ||
|
|
f2600821d6 | ||
|
|
6e08126875 | ||
|
|
176eeca096 | ||
|
|
f6a9ae6335 | ||
|
|
30db67f89c | ||
|
|
da90bafde8 | ||
|
|
04d263b1ed | ||
|
|
e8e79d8446 | ||
|
|
8c24381759 | ||
|
|
8482cdab87 | ||
|
|
064a395c4c | ||
|
|
d103d0aa45 | ||
|
|
9509c8ea00 | ||
|
|
26b73fdaa9 | ||
|
|
a3c9c9cee5 | ||
|
|
0d088962a0 | ||
|
|
2ce4e02ada | ||
|
|
fad3ed1aae | ||
|
|
81444d5603 | ||
|
|
e8b65dbd0b | ||
|
|
f86bb3eab8 | ||
|
|
54a102f029 | ||
|
|
2ee4ec65b4 | ||
|
|
166679cd36 | ||
|
|
b95c54a539 | ||
|
|
e71be53459 | ||
|
|
8c5759d74e | ||
|
|
bd1c4e0690 | ||
|
|
9a428eefe0 | ||
|
|
8774d28bc4 | ||
|
|
9eb9c070cd | ||
|
|
7110a690e1 | ||
|
|
1194e7d51e | ||
|
|
1641f9da5e | ||
|
|
ff4887773e | ||
|
|
15a580ece9 | ||
|
|
b37d258698 | ||
|
|
e0e7bb9190 | ||
|
|
7131c70186 | ||
|
|
be98a59023 | ||
|
|
7e517101a0 | ||
|
|
92f60cceb5 | ||
|
|
b1dcb8a9d7 | ||
|
|
ec6ec7d569 | ||
|
|
31bb069e75 | ||
|
|
363be54303 | ||
|
|
ca0f6661d3 | ||
|
|
cd803cd9bc | ||
|
|
cbdc88c5d0 | ||
|
|
44b548c5c8 | ||
|
|
cc2ac3542d | ||
|
|
25044d40b9 | ||
|
|
676169b189 | ||
|
|
c8c05efb8d | ||
|
|
23cef5fd82 | ||
|
|
c0b0b30541 | ||
|
|
7eeba5f17c | ||
|
|
fcb2457e17 | ||
|
|
02e378905e | ||
|
|
87c0ab6daa | ||
|
|
60f9da9208 | ||
|
|
ff318d6ef5 | ||
|
|
93d9e08de1 | ||
|
|
7edca6b823 | ||
|
|
73eebe7c9e | ||
|
|
049f9a9e37 | ||
|
|
658cbb8bd6 | ||
|
|
19f1c32805 | ||
|
|
ece8ff8cbc | ||
|
|
a3a648aef1 | ||
|
|
3bc2b74d30 | ||
|
|
9d17cd7d9c | ||
|
|
091c6b2737 | ||
|
|
2880314931 | ||
|
|
0102719067 | ||
|
|
123b471b68 | ||
|
|
b66d228460 | ||
|
|
770d67d8c4 | ||
|
|
d42857ec26 | ||
|
|
54b977ee1b | ||
|
|
e8999ba908 | ||
|
|
96c4383b29 | ||
|
|
93d1d2c41a | ||
|
|
b075af5bc9 | ||
|
|
07ca7fccb8 | ||
|
|
797643ffdc | ||
|
|
7d4052be95 | ||
|
|
1036719f2a | ||
|
|
1ab520eda3 | ||
|
|
658f7d816e | ||
|
|
835ab516a6 | ||
|
|
00e098e57d | ||
|
|
f04cac8e2f | ||
|
|
c1b9f1cb28 | ||
|
|
7d8670ff1f | ||
|
|
9f9bcaff65 | ||
|
|
25b1789b0a | ||
|
|
2c8add3b54 | ||
|
|
f25d62fe25 | ||
|
|
7a9f55e1bd | ||
|
|
fbdf1689b3 | ||
|
|
29ba2c5936 | ||
|
|
493050fba1 | ||
|
|
455f6fa95b | ||
|
|
e14957900e | ||
|
|
c0e0f8d214 | ||
|
|
caae869501 | ||
|
|
b998d253bb | ||
|
|
0b1123e3ce | ||
|
|
a412f5d0fb | ||
|
|
919e08689a | ||
|
|
72e803b56d | ||
|
|
e378704c63 | ||
|
|
f6c50ce336 | ||
|
|
063224966c | ||
|
|
5d40e694a5 | ||
|
|
4405a97d9b | ||
|
|
6733de9e0d | ||
|
|
01bae7d43e | ||
|
|
6b30271441 | ||
|
|
cdc8334d82 | ||
|
|
4a3a98b562 | ||
|
|
c280225a4e | ||
|
|
b3ea506a73 | ||
|
|
590437c78b | ||
|
|
a5c61b0546 | ||
|
|
790a1b8e20 | ||
|
|
fa47264c76 | ||
|
|
a4075fb637 | ||
|
|
20a7c8b5a8 | ||
|
|
202494156b | ||
|
|
7558fed4e4 | ||
|
|
480589510e | ||
|
|
999ed5b51b | ||
|
|
589155fa1c | ||
|
|
ae13551033 | ||
|
|
038caeb2a0 | ||
|
|
7b34c9a108 | ||
|
|
5a1fe23ddb | ||
|
|
9bb843f82f | ||
|
|
ebc4f1422a | ||
|
|
96bfa8f131 | ||
|
|
406ba14af5 | ||
|
|
13841b1af6 | ||
|
|
7b3be213e4 | ||
|
|
b52b9ba236 | ||
|
|
58f466b443 | ||
|
|
13e3f05a7a | ||
|
|
7f5cdc0345 | ||
|
|
c21a298e07 | ||
|
|
3d940e21d5 | ||
|
|
6446dd5d3a | ||
|
|
38cff827b3 | ||
|
|
cc4310b368 | ||
|
|
d248e74492 | ||
|
|
a3d74fbe6e | ||
|
|
54dad0135f | ||
|
|
d9440e86a2 | ||
|
|
1f716142af | ||
|
|
f2eb6c3745 | ||
|
|
a08641a59b | ||
|
|
8dc3bdde67 | ||
|
|
223fff9ef9 | ||
|
|
986f6c034f | ||
|
|
10e647570b | ||
|
|
6ac888c5ce | ||
|
|
1bda0259db | ||
|
|
bea115d1e4 | ||
|
|
bc46a18372 | ||
|
|
574680fc11 | ||
|
|
673dcd1113 | ||
|
|
41ae35bcdb | ||
|
|
a2ad1d9420 | ||
|
|
1f4e801c58 | ||
|
|
ff06821fcd | ||
|
|
25edfecbd4 | ||
|
|
aa83583ee9 | ||
|
|
7fe3dff655 | ||
|
|
7c6d9d3723 | ||
|
|
a1ff498585 | ||
|
|
37f45ee89b | ||
|
|
83fbf55781 | ||
|
|
c24cd9721c | ||
|
|
8c33b1c751 | ||
|
|
4d0d15d1d5 | ||
|
|
4ba82e131a | ||
|
|
23ff99d2e2 | ||
|
|
0473b35db3 | ||
|
|
f71533ab17 | ||
|
|
8709b5d34b | ||
|
|
88a059ca52 | ||
|
|
f3ffb22487 | ||
|
|
b915a43eb0 | ||
|
|
8b2b7662ee | ||
|
|
e6d3e8e5a5 | ||
|
|
26e01c930f | ||
|
|
8621a3095d | ||
|
|
6e7352e67e | ||
|
|
7e3f77cb38 | ||
|
|
75b73c55e0 | ||
|
|
ebd928e3b6 | ||
|
|
80cbabeeb0 | ||
|
|
05910905ee | ||
|
|
25f5f7d6b2 | ||
|
|
2f2eab6e02 | ||
|
|
6726050969 | ||
|
|
d08eba2331 | ||
|
|
7cbdb3db73 | ||
|
|
0519aba820 | ||
|
|
a65b16cbae | ||
|
|
55603cb5c7 |
66
.github/actions/setup-project/action.yml
vendored
Normal file
66
.github/actions/setup-project/action.yml
vendored
Normal file
@@ -0,0 +1,66 @@
|
||||
name: "Setup Project"
|
||||
description: "Common setup steps for CI workflows - checkout, Node.js, dependencies, and native modules"
|
||||
|
||||
inputs:
|
||||
node-version:
|
||||
description: "Node.js version to use"
|
||||
required: false
|
||||
default: "22"
|
||||
check-lockfile:
|
||||
description: "Run lockfile lint check for SSH URLs"
|
||||
required: false
|
||||
default: "false"
|
||||
rebuild-node-pty-path:
|
||||
description: "Working directory for node-pty rebuild (empty = root)"
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
cache: "npm"
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Check for SSH URLs in lockfile
|
||||
if: inputs.check-lockfile == 'true'
|
||||
shell: bash
|
||||
run: npm run lint:lockfile
|
||||
|
||||
- name: Configure Git for HTTPS
|
||||
shell: bash
|
||||
# Convert SSH URLs to HTTPS for git dependencies (e.g., @electron/node-gyp)
|
||||
# This is needed because SSH authentication isn't available in CI
|
||||
run: git config --global url."https://github.com/".insteadOf "git@github.com:"
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
# 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
|
||||
|
||||
- name: Install Linux native bindings
|
||||
shell: bash
|
||||
# Workaround for npm optional dependencies bug (npm/cli#4828)
|
||||
# Explicitly install Linux bindings needed for build tools
|
||||
run: |
|
||||
npm install --no-save --force --ignore-scripts \
|
||||
@rollup/rollup-linux-x64-gnu@4.53.3 \
|
||||
@tailwindcss/oxide-linux-x64-gnu@4.1.17
|
||||
|
||||
- name: Rebuild native modules (root)
|
||||
if: inputs.rebuild-node-pty-path == ''
|
||||
shell: bash
|
||||
# Rebuild node-pty and other native modules for Electron
|
||||
run: npm rebuild node-pty
|
||||
|
||||
- name: Rebuild native modules (workspace)
|
||||
if: inputs.rebuild-node-pty-path != ''
|
||||
shell: bash
|
||||
# Rebuild node-pty and other native modules needed for server
|
||||
run: npm rebuild node-pty
|
||||
working-directory: ${{ inputs.rebuild-node-pty-path }}
|
||||
77
.github/workflows/e2e-tests.yml
vendored
Normal file
77
.github/workflows/e2e-tests.yml
vendored
Normal file
@@ -0,0 +1,77 @@
|
||||
name: E2E Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- "*"
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup project
|
||||
uses: ./.github/actions/setup-project
|
||||
with:
|
||||
check-lockfile: "true"
|
||||
rebuild-node-pty-path: "apps/server"
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
working-directory: apps/ui
|
||||
|
||||
- name: Build server
|
||||
run: npm run build --workspace=apps/server
|
||||
|
||||
- name: Start backend server
|
||||
run: npm run start --workspace=apps/server &
|
||||
env:
|
||||
PORT: 3008
|
||||
NODE_ENV: test
|
||||
|
||||
- name: Wait for backend server
|
||||
run: |
|
||||
echo "Waiting for backend server to be ready..."
|
||||
for i in {1..30}; do
|
||||
if curl -s http://localhost:3008/api/health > /dev/null 2>&1; then
|
||||
echo "Backend server is ready!"
|
||||
exit 0
|
||||
fi
|
||||
echo "Waiting... ($i/30)"
|
||||
sleep 1
|
||||
done
|
||||
echo "Backend server failed to start!"
|
||||
exit 1
|
||||
|
||||
- name: Run E2E tests
|
||||
# Playwright automatically starts the Vite frontend via webServer config
|
||||
# (see apps/ui/playwright.config.ts) - no need to start it manually
|
||||
run: npm run test --workspace=apps/ui
|
||||
env:
|
||||
CI: true
|
||||
VITE_SERVER_URL: http://localhost:3008
|
||||
VITE_SKIP_SETUP: "true"
|
||||
|
||||
- name: Upload Playwright report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: apps/ui/playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload test results
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: test-results
|
||||
path: apps/ui/test-results/
|
||||
retention-days: 7
|
||||
17
.github/workflows/pr-check.yml
vendored
17
.github/workflows/pr-check.yml
vendored
@@ -17,17 +17,10 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
- name: Setup project
|
||||
uses: ./.github/actions/setup-project
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
cache-dependency-path: package-lock.json
|
||||
check-lockfile: "true"
|
||||
|
||||
- name: Install dependencies
|
||||
# Use npm install instead of npm ci to correctly resolve platform-specific
|
||||
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
|
||||
run: npm install
|
||||
|
||||
- name: Run build:electron
|
||||
run: npm run build:electron
|
||||
- name: Run build:electron (dir only - faster CI)
|
||||
run: npm run build:electron:dir
|
||||
|
||||
166
.github/workflows/release.yml
vendored
166
.github/workflows/release.yml
vendored
@@ -1,166 +0,0 @@
|
||||
name: Build and Release Electron App
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*" # Triggers on version tags like v1.0.0
|
||||
workflow_dispatch: # Allows manual triggering
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to release (e.g., v1.0.0)"
|
||||
required: true
|
||||
default: "v0.1.0"
|
||||
|
||||
jobs:
|
||||
build-and-release:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-latest
|
||||
name: macOS
|
||||
artifact-name: macos-builds
|
||||
- os: windows-latest
|
||||
name: Windows
|
||||
artifact-name: windows-builds
|
||||
- os: ubuntu-latest
|
||||
name: Linux
|
||||
artifact-name: linux-builds
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
# Use npm install instead of npm ci to correctly resolve platform-specific
|
||||
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
|
||||
run: npm install
|
||||
|
||||
- name: Extract and set version
|
||||
id: version
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION_TAG="${{ github.event.inputs.version || github.ref_name }}"
|
||||
# Remove 'v' prefix if present (e.g., v1.0.0 -> 1.0.0)
|
||||
VERSION="${VERSION_TAG#v}"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Extracted version: $VERSION from tag: $VERSION_TAG"
|
||||
# Update the app's package.json version
|
||||
cd apps/app
|
||||
npm version $VERSION --no-git-tag-version
|
||||
cd ../..
|
||||
echo "Updated apps/app/package.json to version $VERSION"
|
||||
|
||||
- name: Build Electron App (macOS)
|
||||
if: matrix.os == 'macos-latest'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: npm run build:electron -- --mac --x64 --arm64
|
||||
|
||||
- name: Build Electron App (Windows)
|
||||
if: matrix.os == 'windows-latest'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: npm run build:electron -- --win --x64
|
||||
|
||||
- name: Build Electron App (Linux)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: npm run build:electron -- --linux --x64
|
||||
|
||||
- name: Upload Release Assets
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ github.event.inputs.version || github.ref_name }}
|
||||
files: |
|
||||
apps/app/dist/*.exe
|
||||
apps/app/dist/*.dmg
|
||||
apps/app/dist/*.AppImage
|
||||
apps/app/dist/*.zip
|
||||
apps/app/dist/*.deb
|
||||
apps/app/dist/*.rpm
|
||||
draft: false
|
||||
prerelease: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload macOS artifacts for R2
|
||||
if: matrix.os == 'macos-latest'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.artifact-name }}
|
||||
path: apps/app/dist/*.dmg
|
||||
retention-days: 1
|
||||
|
||||
- name: Upload Windows artifacts for R2
|
||||
if: matrix.os == 'windows-latest'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.artifact-name }}
|
||||
path: apps/app/dist/*.exe
|
||||
retention-days: 1
|
||||
|
||||
- name: Upload Linux artifacts for R2
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.artifact-name }}
|
||||
path: apps/app/dist/*.AppImage
|
||||
retention-days: 1
|
||||
|
||||
upload-to-r2:
|
||||
needs: build-and-release
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Install AWS SDK
|
||||
run: npm install @aws-sdk/client-s3
|
||||
|
||||
- name: Extract version
|
||||
id: version
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION_TAG="${{ github.event.inputs.version || github.ref_name }}"
|
||||
# Remove 'v' prefix if present (e.g., v1.0.0 -> 1.0.0)
|
||||
VERSION="${VERSION_TAG#v}"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "version_tag=$VERSION_TAG" >> $GITHUB_OUTPUT
|
||||
echo "Extracted version: $VERSION from tag: $VERSION_TAG"
|
||||
|
||||
- name: Upload to R2 and update releases.json
|
||||
env:
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}
|
||||
R2_PUBLIC_URL: ${{ secrets.R2_PUBLIC_URL }}
|
||||
RELEASE_VERSION: ${{ steps.version.outputs.version }}
|
||||
RELEASE_TAG: ${{ steps.version.outputs.version_tag }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
run: node .github/scripts/upload-to-r2.js
|
||||
39
.github/workflows/test.yml
vendored
Normal file
39
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
name: Test Suite
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- "*"
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup project
|
||||
uses: ./.github/actions/setup-project
|
||||
with:
|
||||
check-lockfile: "true"
|
||||
rebuild-node-pty-path: "apps/server"
|
||||
|
||||
- name: Run server tests with coverage
|
||||
run: npm run test:server:coverage
|
||||
env:
|
||||
NODE_ENV: test
|
||||
|
||||
# - name: Upload coverage reports
|
||||
# uses: codecov/codecov-action@v4
|
||||
# if: always()
|
||||
# with:
|
||||
# files: ./apps/server/coverage/coverage-final.json
|
||||
# flags: server
|
||||
# name: server-coverage
|
||||
# env:
|
||||
# CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
67
.gitignore
vendored
67
.gitignore
vendored
@@ -6,8 +6,75 @@ node_modules/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
out/
|
||||
.next/
|
||||
.turbo/
|
||||
|
||||
# Automaker
|
||||
.automaker/images/
|
||||
.automaker/
|
||||
/.automaker/*
|
||||
/.automaker/
|
||||
|
||||
.worktrees/
|
||||
|
||||
/logs
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# OS-specific files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
Desktop.ini
|
||||
|
||||
# IDE/Editor configs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.sublime-workspace
|
||||
*.sublime-project
|
||||
|
||||
# Editor backup/temp files
|
||||
*~
|
||||
*.bak
|
||||
*.backup
|
||||
*.orig
|
||||
*.swp
|
||||
*.swo
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
# Local settings (user-specific)
|
||||
*.local.json
|
||||
|
||||
# Application state/backup
|
||||
backup.json
|
||||
|
||||
# Test artifacts
|
||||
test-results/
|
||||
coverage/
|
||||
.nyc_output/
|
||||
*.lcov
|
||||
playwright-report/
|
||||
blob-report/
|
||||
|
||||
# Environment files (keep .example)
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
!.env.example
|
||||
!.env.local.example
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Misc
|
||||
*.pem
|
||||
|
||||
6
.npmrc
6
.npmrc
@@ -8,3 +8,9 @@
|
||||
#
|
||||
# In CI/CD: Use "npm install" instead of "npm ci" to allow npm to resolve
|
||||
# the correct platform-specific binaries at install time.
|
||||
|
||||
# Include bindings for all platforms in package-lock.json to support CI/CD
|
||||
# This ensures Linux, macOS, and Windows bindings are all present
|
||||
# NOTE: Only enable when regenerating package-lock.json, then comment out to keep installs fast
|
||||
# supportedArchitectures.os=linux,darwin,win32
|
||||
# supportedArchitectures.cpu=x64,arm64
|
||||
|
||||
35
LICENSE
35
LICENSE
@@ -43,6 +43,10 @@ b) Licensee may run the Software on personal or organizational infrastructure fo
|
||||
|
||||
c) Core Contributors are each individually granted a perpetual, worldwide, royalty-free, non-exclusive license to use, copy, modify, distribute, and sublicense the Software for any purpose, including Monetization, without payment of any fees or royalties. Each Core Contributor may exercise these rights independently and does not require permission, consent, or approval from any other Core Contributor to Monetize the Software in any way they see fit.
|
||||
|
||||
d) Commercial licenses for the Software may be discussed and issued to external parties or companies seeking to use the Software for financial gain or Monetization purposes. Core Contributors already have full rights under section 2(c) and do not require commercial licenses. Any commercial license issued to external parties shall require a unanimous vote by all Core Contributors and shall be granted in writing and signed by all Core Contributors.
|
||||
|
||||
e) The list of individuals defined as "Core Contributors" in Section 1 shall be amended to reflect any revocation or reinstatement of status made under this section.
|
||||
|
||||
3. RESTRICTIONS
|
||||
|
||||
Licensee may NOT:
|
||||
@@ -63,7 +67,24 @@ Licensee MAY:
|
||||
- Use the Software to build other commercial products (products that do NOT contain the Software or Derivative Works)
|
||||
- Modify the Software for internal use within their organization (commercial or non-profit)
|
||||
|
||||
4. CONTRIBUTIONS AND RIGHTS ASSIGNMENT
|
||||
4. CORE CONTRIBUTOR STATUS MANAGEMENT
|
||||
|
||||
a) Core Contributor status may be revoked indefinitely by the remaining Core Contributors if:
|
||||
|
||||
- A Core Contributor cannot be reached for a period of one (1) month through reasonable means of communication (including but not limited to email, Discord, GitHub, or other project communication channels)
|
||||
- AND the Core Contributor has not contributed to the project during that one-month period. For purposes of this section, "contributed" means at least one of the following activities:
|
||||
- Discussing the Software through project communication channels
|
||||
- Committing code changes to the project repository
|
||||
- Submitting bug fixes or patches
|
||||
- Participating in project-related discussions or decision-making
|
||||
|
||||
b) Revocation of Core Contributor status requires a unanimous vote by all other Core Contributors (excluding the Core Contributor whose status is being considered for revocation).
|
||||
|
||||
c) Upon revocation of Core Contributor status, the individual shall no longer be considered a Core Contributor and shall lose the rights granted under section 2(c) of this Agreement. However, any Contributions made prior to revocation shall remain subject to the terms of section 5 (CONTRIBUTIONS AND RIGHTS ASSIGNMENT).
|
||||
|
||||
d) A revoked Core Contributor may be reinstated to Core Contributor status with a unanimous vote by all current Core Contributors. Upon reinstatement, the individual shall regain all rights granted under section 2(c) of this Agreement.
|
||||
|
||||
5. CONTRIBUTIONS AND RIGHTS ASSIGNMENT
|
||||
|
||||
By submitting, pushing, or contributing any code, documentation, pull requests, issues, or other materials ("Contributions") to the Automaker project, you agree to the following terms without reservation:
|
||||
|
||||
@@ -75,11 +96,11 @@ c) **Waiver of Moral Rights:** You waive any "moral rights" or other rights with
|
||||
|
||||
d) **Right to Contribute:** You represent and warrant that you are the original author of the Contributions, or that you have sufficient rights to grant the rights conveyed by this section, and that your Contributions do not infringe upon the rights of any third party.
|
||||
|
||||
5. TERMINATION
|
||||
6. TERMINATION
|
||||
|
||||
This license will terminate automatically if Licensee breaches any term of this Agreement. Upon termination, Licensee must immediately cease all use of the Software and destroy all copies in their possession.
|
||||
|
||||
6. HIGH RISK DISCLAIMER AND LIMITATION OF LIABILITY
|
||||
7. HIGH RISK DISCLAIMER AND LIMITATION OF LIABILITY
|
||||
|
||||
a) **AI RISKS:** THE SOFTWARE UTILIZES ARTIFICIAL INTELLIGENCE TO GENERATE CODE, EXECUTE COMMANDS, AND INTERACT WITH YOUR FILE SYSTEM. YOU ACKNOWLEDGE THAT AI SYSTEMS CAN BE UNPREDICTABLE, MAY GENERATE INCORRECT, INSECURE, OR DESTRUCTIVE CODE, AND MAY TAKE ACTIONS THAT COULD DAMAGE YOUR SYSTEM, FILES, OR HARDWARE.
|
||||
|
||||
@@ -95,7 +116,11 @@ d) **LIMITATION OF LIABILITY:** IN NO EVENT SHALL THE CORE CONTRIBUTORS, LICENSO
|
||||
- FINANCIAL LOSSES
|
||||
- BUSINESS INTERRUPTION
|
||||
|
||||
7. CONTACT
|
||||
8. LICENSE AMENDMENTS
|
||||
|
||||
Any amendment, modification, or update to this License Agreement must be agreed upon unanimously by all Core Contributors. No changes to this Agreement shall be effective unless all Core Contributors have provided their written consent or approval through a unanimous vote.
|
||||
|
||||
9. CONTACT
|
||||
|
||||
For inquiries regarding this license or permissions for Monetization, please contact the Core Contributors through the official project channels:
|
||||
|
||||
@@ -105,7 +130,7 @@ For inquiries regarding this license or permissions for Monetization, please con
|
||||
|
||||
Any permission for Monetization requires the unanimous written consent of all Core Contributors.
|
||||
|
||||
8. GOVERNING LAW
|
||||
10. GOVERNING LAW
|
||||
|
||||
This Agreement shall be governed by and construed in accordance with the laws of the State of Tennessee, USA, without regard to conflict of law principles.
|
||||
|
||||
|
||||
125
README.md
125
README.md
@@ -1,6 +1,71 @@
|
||||
<p align="center">
|
||||
<img src="apps/ui/public/readme_logo.png" alt="Automaker Logo" height="80" />
|
||||
</p>
|
||||
|
||||
> **[!TIP]**
|
||||
>
|
||||
> **Learn more about Agentic Coding!**
|
||||
>
|
||||
> Automaker itself was built by a group of engineers using AI and agentic coding techniques to build features faster than ever. By leveraging tools like Cursor IDE and Claude Code CLI, the team orchestrated AI agents to implement complex functionality in days instead of weeks.
|
||||
>
|
||||
> **Learn how:** Master these same techniques and workflows in the [Agentic Jumpstart course](https://agenticjumpstart.com/?utm=automaker).
|
||||
|
||||
# Automaker
|
||||
|
||||
Automaker is an autonomous AI development studio that helps you build software faster using AI-powered agents. It provides a visual Kanban board interface to manage features, automatically assigns AI agents to implement them, and tracks progress through an intuitive workflow from backlog to verified completion.
|
||||
**Stop typing code. Start directing AI agents.**
|
||||
|
||||
<details open>
|
||||
<summary><h2>Table of Contents</h2></summary>
|
||||
|
||||
- [What Makes Automaker Different?](#what-makes-automaker-different)
|
||||
- [The Workflow](#the-workflow)
|
||||
- [Powered by Claude Code](#powered-by-claude-code)
|
||||
- [Why This Matters](#why-this-matters)
|
||||
- [Security Disclaimer](#security-disclaimer)
|
||||
- [Community & Support](#community--support)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Quick Start](#quick-start)
|
||||
- [How to Run](#how-to-run)
|
||||
- [Development Mode](#development-mode)
|
||||
- [Electron Desktop App (Recommended)](#electron-desktop-app-recommended)
|
||||
- [Web Browser Mode](#web-browser-mode)
|
||||
- [Building for Production](#building-for-production)
|
||||
- [Running Production Build](#running-production-build)
|
||||
- [Testing](#testing)
|
||||
- [Linting](#linting)
|
||||
- [Authentication Options](#authentication-options)
|
||||
- [Persistent Setup (Optional)](#persistent-setup-optional)
|
||||
- [Features](#features)
|
||||
- [Tech Stack](#tech-stack)
|
||||
- [Learn More](#learn-more)
|
||||
- [License](#license)
|
||||
|
||||
</details>
|
||||
|
||||
Automaker is an autonomous AI development studio that transforms how you build software. Instead of manually writing every line of code, you describe features on a Kanban board and watch as AI agents powered by Claude Code automatically implement them.
|
||||
|
||||

|
||||
|
||||
## What Makes Automaker Different?
|
||||
|
||||
Traditional development tools help you write code. Automaker helps you **orchestrate AI agents** to build entire features autonomously. Think of it as having a team of AI developers working for you—you define what needs to be built, and Automaker handles the implementation.
|
||||
|
||||
### The Workflow
|
||||
|
||||
1. **Add Features** - Describe features you want built (with text, images, or screenshots)
|
||||
2. **Move to "In Progress"** - Automaker automatically assigns an AI agent to implement the feature
|
||||
3. **Watch It Build** - See real-time progress as the agent writes code, runs tests, and makes changes
|
||||
4. **Review & Verify** - Review the changes, run tests, and approve when ready
|
||||
5. **Ship Faster** - Build entire applications in days, not weeks
|
||||
|
||||
### Powered by Claude Code
|
||||
|
||||
Automaker leverages the [Claude Agent SDK](https://docs.anthropic.com/en/docs/claude-code) to give AI agents full access to your codebase. Agents can read files, write code, execute commands, run tests, and make git commits—all while working in isolated git worktrees to keep your main branch safe.
|
||||
|
||||
### Why This Matters
|
||||
|
||||
The future of software development is **agentic coding**—where developers become architects directing AI agents rather than manual coders. Automaker puts this future in your hands today, letting you experience what it's like to build software 10x faster with AI agents handling the implementation while you focus on architecture and business logic.
|
||||
|
||||
---
|
||||
|
||||
@@ -18,6 +83,23 @@ Automaker is an autonomous AI development studio that helps you build software f
|
||||
|
||||
---
|
||||
|
||||
## Community & Support
|
||||
|
||||
Join the **Agentic Jumpstart** to connect with other builders exploring **agentic coding** and autonomous development workflows.
|
||||
|
||||
In the Discord, you can:
|
||||
|
||||
- 💬 Discuss agentic coding patterns and best practices
|
||||
- 🧠 Share ideas for AI-driven development workflows
|
||||
- 🛠️ Get help setting up or extending Automaker
|
||||
- 🚀 Show off projects built with AI agents
|
||||
- 🤝 Collaborate with other developers and contributors
|
||||
|
||||
👉 **Join the Discord:**
|
||||
https://discord.gg/jjem7aEDKU
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
@@ -30,26 +112,28 @@ Automaker is an autonomous AI development studio that helps you build software f
|
||||
|
||||
```bash
|
||||
# 1. Clone the repo
|
||||
git clone git@github.com:AutoMaker-Org/automaker.git
|
||||
git clone https://github.com/AutoMaker-Org/automaker.git
|
||||
cd automaker
|
||||
|
||||
# 2. Install dependencies
|
||||
npm install
|
||||
|
||||
# 3. Get your Claude Code OAuth token
|
||||
claude setup-token
|
||||
# ⚠️ This prints your token - don't share your screen!
|
||||
|
||||
# 4. Set the token and run
|
||||
export CLAUDE_CODE_OAUTH_TOKEN="sk-ant-oat01-..."
|
||||
npm run dev:electron
|
||||
# 3. Run Automaker (pick your mode)
|
||||
npm run dev
|
||||
# Then choose your run mode when prompted, or use specific commands below
|
||||
```
|
||||
|
||||
## How to Run
|
||||
|
||||
### Development Modes
|
||||
### Development Mode
|
||||
|
||||
Automaker can be run in several modes:
|
||||
Start Automaker in development mode:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
This will prompt you to choose your run mode, or you can specify a mode directly:
|
||||
|
||||
#### Electron Desktop App (Recommended)
|
||||
|
||||
@@ -72,8 +156,6 @@ npm run dev:electron:wsl:gpu
|
||||
```bash
|
||||
# Run in web browser (http://localhost:3007)
|
||||
npm run dev:web
|
||||
# or
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Building for Production
|
||||
@@ -114,21 +196,17 @@ npm run lint
|
||||
|
||||
Automaker supports multiple authentication methods (in order of priority):
|
||||
|
||||
| Method | Environment Variable | Description |
|
||||
| -------------------- | ------------------------- | --------------------------------------------------------- |
|
||||
| OAuth Token (env) | `CLAUDE_CODE_OAUTH_TOKEN` | From `claude setup-token` - uses your Claude subscription |
|
||||
| OAuth Token (stored) | — | Stored in app credentials file |
|
||||
| API Key (stored) | — | Anthropic API key stored in app |
|
||||
| API Key (env) | `ANTHROPIC_API_KEY` | Pay-per-use API key |
|
||||
|
||||
**Recommended:** Use `CLAUDE_CODE_OAUTH_TOKEN` if you have a Claude subscription.
|
||||
| Method | Environment Variable | Description |
|
||||
| ---------------- | -------------------- | ------------------------------- |
|
||||
| API Key (env) | `ANTHROPIC_API_KEY` | Anthropic API key |
|
||||
| API Key (stored) | — | Anthropic API key stored in app |
|
||||
|
||||
### Persistent Setup (Optional)
|
||||
|
||||
Add to your `~/.bashrc` or `~/.zshrc`:
|
||||
|
||||
```bash
|
||||
export CLAUDE_CODE_OAUTH_TOKEN="YOUR_TOKEN_HERE"
|
||||
export ANTHROPIC_API_KEY="YOUR_API_KEY_HERE"
|
||||
```
|
||||
|
||||
Then restart your terminal or run `source ~/.bashrc`.
|
||||
@@ -175,19 +253,16 @@ This project is licensed under the **Automaker License Agreement**. See [LICENSE
|
||||
**Summary of Terms:**
|
||||
|
||||
- **Allowed:**
|
||||
|
||||
- **Build Anything:** You can clone and use Automaker locally or in your organization to build ANY product (commercial or free).
|
||||
- **Internal Use:** You can use it internally within your company (commercial or non-profit) without restriction.
|
||||
- **Modify:** You can modify the code for internal use within your organization (commercial or non-profit).
|
||||
|
||||
- **Restricted (The "No Monetization of the Tool" Rule):**
|
||||
|
||||
- **No Resale:** You cannot resell Automaker itself.
|
||||
- **No SaaS:** You cannot host Automaker as a service for others.
|
||||
- **No Monetizing Mods:** You cannot distribute modified versions of Automaker for money.
|
||||
|
||||
- **Liability:**
|
||||
|
||||
- **Use at Own Risk:** This tool uses AI. We are **NOT** responsible if it breaks your computer, deletes your files, or generates bad code. You assume all risk.
|
||||
|
||||
- **Contributing:**
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
# Automaker
|
||||
|
||||
Automaker is an autonomous AI development studio that helps you build software faster using AI-powered agents. It provides a visual Kanban board interface to manage features, automatically assigns AI agents to implement them, and tracks progress through an intuitive workflow from backlog to verified completion.
|
||||
|
||||
---
|
||||
|
||||
> **[!CAUTION]**
|
||||
>
|
||||
> ## Security Disclaimer
|
||||
>
|
||||
> **This software uses AI-powered tooling that has access to your operating system and can read, modify, and delete files. Use at your own risk.**
|
||||
>
|
||||
> We have reviewed this codebase for security vulnerabilities, but you assume all risk when running this software. You should review the code yourself before running it.
|
||||
>
|
||||
> **We do not recommend running Automaker directly on your local computer** due to the risk of AI agents having access to your entire file system. Please sandbox this application using Docker or a virtual machine.
|
||||
>
|
||||
> **[Read the full disclaimer](../DISCLAIMER.md)**
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
**Step 1:** Clone this repository:
|
||||
|
||||
```bash
|
||||
git clone git@github.com:AutoMaker-Org/automaker.git
|
||||
cd automaker
|
||||
```
|
||||
|
||||
**Step 2:** Install dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Windows notes (in-app Claude auth)
|
||||
|
||||
- Node.js 22.x
|
||||
- Prebuilt PTY is bundled; Visual Studio build tools are not required for Claude auth.
|
||||
- If you prefer the external terminal flow, set `CLAUDE_AUTH_DISABLE_PTY=1`.
|
||||
- If you later add native modules beyond the prebuilt PTY, you may still need VS Build Tools + Python to rebuild those.
|
||||
|
||||
**Step 3:** Run the Claude Code setup token command:
|
||||
|
||||
```bash
|
||||
claude setup-token
|
||||
```
|
||||
|
||||
> **⚠️ Warning:** This command will print your token to your terminal. Be careful if you're streaming or sharing your screen, as the token will be visible to anyone watching.
|
||||
|
||||
**Step 4:** Export the Claude Code OAuth token in your shell:
|
||||
|
||||
```bash
|
||||
export CLAUDE_CODE_OAUTH_TOKEN="your-token-here"
|
||||
```
|
||||
|
||||
**Step 5:** Start the development server:
|
||||
|
||||
```bash
|
||||
npm run dev:electron
|
||||
```
|
||||
|
||||
This will start both the Next.js development server and the Electron application.
|
||||
|
||||
### Auth smoke test (Windows)
|
||||
|
||||
1. Ensure dependencies are installed (prebuilt pty is included).
|
||||
2. Run `npm run dev:electron` and open the Setup modal.
|
||||
3. Click Start on Claude auth; watch the embedded terminal stream logs.
|
||||
4. Successful runs show “Token captured automatically.”; otherwise copy/paste the token from the log.
|
||||
5. Optional: `node --test tests/claude-cli-detector.test.js` to verify token parsing.
|
||||
|
||||
**Step 6:** MOST IMPORTANT: Run the Following after all is setup
|
||||
|
||||
```bash
|
||||
echo "W"
|
||||
echo "W"
|
||||
echo "W"
|
||||
echo "W"
|
||||
echo "W"
|
||||
echo "W"
|
||||
echo "W"
|
||||
echo "W"
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- 📋 **Kanban Board** - Visual drag-and-drop board to manage features through backlog, in progress, waiting approval, and verified stages
|
||||
- 🤖 **AI Agent Integration** - Automatic AI agent assignment to implement features when moved to "In Progress"
|
||||
- 🧠 **Multi-Model Support** - Choose from multiple AI models including Claude Opus, Sonnet, and more
|
||||
- 💭 **Extended Thinking** - Enable extended thinking modes for complex problem-solving
|
||||
- 📡 **Real-time Agent Output** - View live agent output, logs, and file diffs as features are being implemented
|
||||
- 🔍 **Project Analysis** - AI-powered project structure analysis to understand your codebase
|
||||
- 📁 **Context Management** - Add context files to help AI agents understand your project better
|
||||
- 💡 **Feature Suggestions** - AI-generated feature suggestions based on your project
|
||||
- 🖼️ **Image Support** - Attach images and screenshots to feature descriptions
|
||||
- ⚡ **Concurrent Processing** - Configure concurrency to process multiple features simultaneously
|
||||
- 🧪 **Test Integration** - Automatic test running and verification for implemented features
|
||||
- 🔀 **Git Integration** - View git diffs and track changes made by AI agents
|
||||
- 👤 **AI Profiles** - Create and manage different AI agent profiles for various tasks
|
||||
- 💬 **Chat History** - Keep track of conversations and interactions with AI agents
|
||||
- ⌨️ **Keyboard Shortcuts** - Efficient navigation and actions via keyboard shortcuts
|
||||
- 🎨 **Dark/Light Theme** - Beautiful UI with theme support
|
||||
- 🖥️ **Cross-Platform** - Desktop application built with Electron for Windows, macOS, and Linux
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- [Next.js](https://nextjs.org) - React framework
|
||||
- [Electron](https://www.electronjs.org/) - Desktop application framework
|
||||
- [Tailwind CSS](https://tailwindcss.com/) - Styling
|
||||
- [Zustand](https://zustand-demo.pmnd.rs/) - State management
|
||||
- [dnd-kit](https://dndkit.com/) - Drag and drop functionality
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
## License
|
||||
|
||||
See [LICENSE](../LICENSE) for details.
|
||||
@@ -1,5 +0,0 @@
|
||||
module.exports = {
|
||||
rules: {
|
||||
"@typescript-eslint/no-require-imports": "off",
|
||||
},
|
||||
};
|
||||
@@ -1,277 +0,0 @@
|
||||
/**
|
||||
* Simplified Electron main process
|
||||
*
|
||||
* This version spawns the backend server and uses HTTP API for most operations.
|
||||
* Only native features (dialogs, shell) use IPC.
|
||||
*/
|
||||
|
||||
const path = require("path");
|
||||
const { spawn } = require("child_process");
|
||||
const fs = require("fs");
|
||||
|
||||
// Load environment variables from .env file
|
||||
require("dotenv").config({ path: path.join(__dirname, "../.env") });
|
||||
|
||||
const { app, BrowserWindow, ipcMain, dialog, shell } = require("electron");
|
||||
|
||||
let mainWindow = null;
|
||||
let serverProcess = null;
|
||||
const SERVER_PORT = 3008;
|
||||
|
||||
// Get icon path - works in both dev and production
|
||||
function getIconPath() {
|
||||
return app.isPackaged
|
||||
? path.join(process.resourcesPath, "app", "public", "logo.png")
|
||||
: path.join(__dirname, "../public/logo.png");
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the backend server
|
||||
*/
|
||||
async function startServer() {
|
||||
const isDev = !app.isPackaged;
|
||||
|
||||
// Server entry point - use tsx in dev, compiled version in production
|
||||
let command, args, serverPath;
|
||||
if (isDev) {
|
||||
// In development, use tsx to run TypeScript directly
|
||||
// Use the node executable that's running Electron
|
||||
command = process.execPath; // This is the path to node.exe
|
||||
serverPath = path.join(__dirname, "../../server/src/index.ts");
|
||||
|
||||
// Find tsx CLI - check server node_modules first, then root
|
||||
const serverNodeModules = path.join(__dirname, "../../server/node_modules/tsx");
|
||||
const rootNodeModules = path.join(__dirname, "../../../node_modules/tsx");
|
||||
|
||||
let tsxCliPath;
|
||||
if (fs.existsSync(path.join(serverNodeModules, "dist/cli.mjs"))) {
|
||||
tsxCliPath = path.join(serverNodeModules, "dist/cli.mjs");
|
||||
} else if (fs.existsSync(path.join(rootNodeModules, "dist/cli.mjs"))) {
|
||||
tsxCliPath = path.join(rootNodeModules, "dist/cli.mjs");
|
||||
} else {
|
||||
// Last resort: try require.resolve
|
||||
try {
|
||||
tsxCliPath = require.resolve("tsx/cli.mjs", { paths: [path.join(__dirname, "../../server")] });
|
||||
} catch {
|
||||
throw new Error("Could not find tsx. Please run 'npm install' in the server directory.");
|
||||
}
|
||||
}
|
||||
|
||||
args = [tsxCliPath, "watch", serverPath];
|
||||
} else {
|
||||
// In production, use compiled JavaScript
|
||||
command = "node";
|
||||
serverPath = path.join(process.resourcesPath, "server", "index.js");
|
||||
args = [serverPath];
|
||||
}
|
||||
|
||||
// Set environment variables for server
|
||||
const env = {
|
||||
...process.env,
|
||||
PORT: SERVER_PORT.toString(),
|
||||
DATA_DIR: app.getPath("userData"),
|
||||
};
|
||||
|
||||
console.log("[Electron] Starting backend server...");
|
||||
|
||||
serverProcess = spawn(command, args, {
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
serverProcess.stdout.on("data", (data) => {
|
||||
console.log(`[Server] ${data.toString().trim()}`);
|
||||
});
|
||||
|
||||
serverProcess.stderr.on("data", (data) => {
|
||||
console.error(`[Server Error] ${data.toString().trim()}`);
|
||||
});
|
||||
|
||||
serverProcess.on("close", (code) => {
|
||||
console.log(`[Server] Process exited with code ${code}`);
|
||||
serverProcess = null;
|
||||
});
|
||||
|
||||
// Wait for server to be ready
|
||||
await waitForServer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for server to be available
|
||||
*/
|
||||
async function waitForServer(maxAttempts = 30) {
|
||||
const http = require("http");
|
||||
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const req = http.get(`http://localhost:${SERVER_PORT}/api/health`, (res) => {
|
||||
if (res.statusCode === 200) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Status: ${res.statusCode}`));
|
||||
}
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.setTimeout(1000, () => {
|
||||
req.destroy();
|
||||
reject(new Error("Timeout"));
|
||||
});
|
||||
});
|
||||
console.log("[Electron] Server is ready");
|
||||
return;
|
||||
} catch {
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Server failed to start");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the main window
|
||||
*/
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1400,
|
||||
height: 900,
|
||||
minWidth: 1024,
|
||||
minHeight: 700,
|
||||
icon: getIconPath(),
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "preload.js"),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
titleBarStyle: "hiddenInset",
|
||||
backgroundColor: "#0a0a0a",
|
||||
});
|
||||
|
||||
// Load Next.js dev server in development or production build
|
||||
const isDev = !app.isPackaged;
|
||||
if (isDev) {
|
||||
mainWindow.loadURL("http://localhost:3007");
|
||||
if (process.env.OPEN_DEVTOOLS === "true") {
|
||||
mainWindow.webContents.openDevTools();
|
||||
}
|
||||
} else {
|
||||
mainWindow.loadFile(path.join(__dirname, "../.next/server/app/index.html"));
|
||||
}
|
||||
|
||||
mainWindow.on("closed", () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
|
||||
// Handle external links - open in default browser
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
shell.openExternal(url);
|
||||
return { action: "deny" };
|
||||
});
|
||||
}
|
||||
|
||||
// App lifecycle
|
||||
app.whenReady().then(async () => {
|
||||
// Set app icon (dock icon on macOS)
|
||||
if (process.platform === "darwin" && app.dock) {
|
||||
app.dock.setIcon(getIconPath());
|
||||
}
|
||||
|
||||
try {
|
||||
// Start backend server
|
||||
await startServer();
|
||||
|
||||
// Create window
|
||||
createWindow();
|
||||
} catch (error) {
|
||||
console.error("[Electron] Failed to start:", error);
|
||||
app.quit();
|
||||
}
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on("before-quit", () => {
|
||||
// Kill server process
|
||||
if (serverProcess) {
|
||||
console.log("[Electron] Stopping server...");
|
||||
serverProcess.kill();
|
||||
serverProcess = null;
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// IPC Handlers - Only native features
|
||||
// ============================================
|
||||
|
||||
// Native file dialogs
|
||||
ipcMain.handle("dialog:openDirectory", async () => {
|
||||
const result = await dialog.showOpenDialog(mainWindow, {
|
||||
properties: ["openDirectory", "createDirectory"],
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
ipcMain.handle("dialog:openFile", async (_, options = {}) => {
|
||||
const result = await dialog.showOpenDialog(mainWindow, {
|
||||
properties: ["openFile"],
|
||||
...options,
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
ipcMain.handle("dialog:saveFile", async (_, options = {}) => {
|
||||
const result = await dialog.showSaveDialog(mainWindow, options);
|
||||
return result;
|
||||
});
|
||||
|
||||
// Shell operations
|
||||
ipcMain.handle("shell:openExternal", async (_, url) => {
|
||||
try {
|
||||
await shell.openExternal(url);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("shell:openPath", async (_, filePath) => {
|
||||
try {
|
||||
await shell.openPath(filePath);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// App info
|
||||
ipcMain.handle("app:getPath", async (_, name) => {
|
||||
return app.getPath(name);
|
||||
});
|
||||
|
||||
ipcMain.handle("app:getVersion", async () => {
|
||||
return app.getVersion();
|
||||
});
|
||||
|
||||
ipcMain.handle("app:isPackaged", async () => {
|
||||
return app.isPackaged;
|
||||
});
|
||||
|
||||
// Ping - for connection check
|
||||
ipcMain.handle("ping", async () => {
|
||||
return "pong";
|
||||
});
|
||||
|
||||
// Get server URL for HTTP client
|
||||
ipcMain.handle("server:getUrl", async () => {
|
||||
return `http://localhost:${SERVER_PORT}`;
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
/**
|
||||
* Simplified Electron preload script
|
||||
*
|
||||
* Only exposes native features (dialogs, shell) and server URL.
|
||||
* All other operations go through HTTP API.
|
||||
*/
|
||||
|
||||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
|
||||
// Expose minimal API for native features
|
||||
contextBridge.exposeInMainWorld("electronAPI", {
|
||||
// Platform info
|
||||
platform: process.platform,
|
||||
isElectron: true,
|
||||
|
||||
// Connection check
|
||||
ping: () => ipcRenderer.invoke("ping"),
|
||||
|
||||
// Get server URL for HTTP client
|
||||
getServerUrl: () => ipcRenderer.invoke("server:getUrl"),
|
||||
|
||||
// Native dialogs - better UX than prompt()
|
||||
openDirectory: () => ipcRenderer.invoke("dialog:openDirectory"),
|
||||
openFile: (options) => ipcRenderer.invoke("dialog:openFile", options),
|
||||
saveFile: (options) => ipcRenderer.invoke("dialog:saveFile", options),
|
||||
|
||||
// Shell operations
|
||||
openExternalLink: (url) => ipcRenderer.invoke("shell:openExternal", url),
|
||||
openPath: (filePath) => ipcRenderer.invoke("shell:openPath", filePath),
|
||||
|
||||
// App info
|
||||
getPath: (name) => ipcRenderer.invoke("app:getPath", name),
|
||||
getVersion: () => ipcRenderer.invoke("app:getVersion"),
|
||||
isPackaged: () => ipcRenderer.invoke("app:isPackaged"),
|
||||
});
|
||||
|
||||
console.log("[Preload] Electron API exposed (simplified mode)");
|
||||
@@ -1,20 +0,0 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
// Electron files use CommonJS
|
||||
"electron/**",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
env: {
|
||||
CLAUDE_CODE_OAUTH_TOKEN: process.env.CLAUDE_CODE_OAUTH_TOKEN || "",
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -1,35 +0,0 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const port = process.env.TEST_PORT || 3007;
|
||||
const reuseServer = process.env.TEST_REUSE_SERVER === "true";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: "html",
|
||||
timeout: 30000,
|
||||
use: {
|
||||
baseURL: `http://localhost:${port}`,
|
||||
trace: "on-first-retry",
|
||||
screenshot: "only-on-failure",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
...(reuseServer
|
||||
? {}
|
||||
: {
|
||||
webServer: {
|
||||
command: `npx next dev -p ${port}`,
|
||||
url: `http://localhost:${port}`,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120000,
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const port = process.env.TEST_PORT || 3007;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: "html",
|
||||
timeout: 10000,
|
||||
use: {
|
||||
baseURL: `http://localhost:${port}`,
|
||||
trace: "on-first-retry",
|
||||
screenshot: "only-on-failure",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: `npx next dev -p ${port}`,
|
||||
url: `http://localhost:${port}`,
|
||||
reuseExistingServer: true,
|
||||
timeout: 60000,
|
||||
},
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 142 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 147 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 108 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 262 KiB |
Binary file not shown.
@@ -1,172 +0,0 @@
|
||||
import {
|
||||
query,
|
||||
Options,
|
||||
SDKAssistantMessage,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import path from "path";
|
||||
|
||||
const systemPrompt = `You are an AI assistant helping users build software. You are part of the Automaker application,
|
||||
which is designed to help developers plan, design, and implement software projects autonomously.
|
||||
|
||||
Your role is to:
|
||||
- Help users define their project requirements and specifications
|
||||
- Ask clarifying questions to better understand their needs
|
||||
- Suggest technical approaches and architectures
|
||||
- Guide them through the development process
|
||||
- Be conversational and helpful
|
||||
- Write, edit, and modify code files as requested
|
||||
- Execute commands and tests
|
||||
- Search and analyze the codebase
|
||||
|
||||
When discussing projects, help users think through:
|
||||
- Core functionality and features
|
||||
- Technical stack choices
|
||||
- Data models and architecture
|
||||
- User experience considerations
|
||||
- Testing strategies
|
||||
|
||||
You have full access to the codebase and can:
|
||||
- Read files to understand existing code
|
||||
- Write new files
|
||||
- Edit existing files
|
||||
- Run bash commands
|
||||
- Search for code patterns
|
||||
- Execute tests and builds`;
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { messages, workingDirectory } = await request.json();
|
||||
|
||||
console.log(
|
||||
"[API] CLAUDE_CODE_OAUTH_TOKEN present:",
|
||||
!!process.env.CLAUDE_CODE_OAUTH_TOKEN
|
||||
);
|
||||
|
||||
if (!process.env.CLAUDE_CODE_OAUTH_TOKEN) {
|
||||
return NextResponse.json(
|
||||
{ error: "CLAUDE_CODE_OAUTH_TOKEN not configured" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get the last user message
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
|
||||
// Determine working directory - default to parent of app directory
|
||||
const cwd = workingDirectory || path.resolve(process.cwd(), "..");
|
||||
|
||||
console.log("[API] Working directory:", cwd);
|
||||
|
||||
// Create query with options that enable code modification
|
||||
const options: Options = {
|
||||
// model: "claude-sonnet-4-20250514",
|
||||
model: "claude-opus-4-5-20251101",
|
||||
systemPrompt,
|
||||
maxTurns: 20,
|
||||
cwd,
|
||||
// Enable all core tools for code modification
|
||||
allowedTools: [
|
||||
"Read",
|
||||
"Write",
|
||||
"Edit",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"Bash",
|
||||
"WebSearch",
|
||||
"WebFetch",
|
||||
],
|
||||
// Auto-accept file edits within the working directory
|
||||
permissionMode: "acceptEdits",
|
||||
// Enable sandbox for safer bash execution
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
autoAllowBashIfSandboxed: true,
|
||||
},
|
||||
};
|
||||
|
||||
// Convert message history to SDK format to preserve conversation context
|
||||
// Include both user and assistant messages for full context
|
||||
const sessionId = `api-session-${Date.now()}`;
|
||||
const conversationMessages = messages.map(
|
||||
(msg: { role: string; content: string }) => {
|
||||
if (msg.role === "user") {
|
||||
return {
|
||||
type: "user" as const,
|
||||
message: {
|
||||
role: "user" as const,
|
||||
content: msg.content,
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
session_id: sessionId,
|
||||
};
|
||||
} else {
|
||||
// Assistant message
|
||||
return {
|
||||
type: "assistant" as const,
|
||||
message: {
|
||||
role: "assistant" as const,
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: msg.content,
|
||||
},
|
||||
],
|
||||
},
|
||||
session_id: sessionId,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Execute query with full conversation context
|
||||
const queryResult = query({
|
||||
prompt:
|
||||
conversationMessages.length > 0
|
||||
? conversationMessages
|
||||
: lastMessage.content,
|
||||
options,
|
||||
});
|
||||
|
||||
let responseText = "";
|
||||
const toolUses: Array<{ name: string; input: unknown }> = [];
|
||||
|
||||
// Collect the response from the async generator
|
||||
for await (const msg of queryResult) {
|
||||
if (msg.type === "assistant") {
|
||||
const assistantMsg = msg as SDKAssistantMessage;
|
||||
if (assistantMsg.message.content) {
|
||||
for (const block of assistantMsg.message.content) {
|
||||
if (block.type === "text") {
|
||||
responseText += block.text;
|
||||
} else if (block.type === "tool_use") {
|
||||
// Track tool usage for transparency
|
||||
toolUses.push({
|
||||
name: block.name,
|
||||
input: block.input,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (msg.type === "result") {
|
||||
if (msg.subtype === "success") {
|
||||
if (msg.result) {
|
||||
responseText = msg.result;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
content: responseText || "Sorry, I couldn't generate a response.",
|
||||
toolUses: toolUses.length > 0 ? toolUses : undefined,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error("Claude API error:", error);
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to get response from Claude";
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
interface AnthropicResponse {
|
||||
content?: Array<{ type: string; text?: string }>;
|
||||
model?: string;
|
||||
error?: { message?: string };
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { apiKey } = await request.json();
|
||||
|
||||
// Use provided API key or fall back to environment variable
|
||||
const effectiveApiKey = apiKey || process.env.ANTHROPIC_API_KEY || process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
|
||||
if (!effectiveApiKey) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "No API key provided or configured in environment" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Send a simple test prompt to the Anthropic API
|
||||
const response = await fetch("https://api.anthropic.com/v1/messages", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": effectiveApiKey,
|
||||
"anthropic-version": "2023-06-01",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "claude-sonnet-4-20250514",
|
||||
max_tokens: 100,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Respond with exactly: 'Claude API connection successful!' and nothing else.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = (await response.json()) as AnthropicResponse;
|
||||
const errorMessage = errorData.error?.message || `HTTP ${response.status}`;
|
||||
|
||||
if (response.status === 401) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Invalid API key. Please check your Anthropic API key." },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Rate limit exceeded. Please try again later." },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `API error: ${errorMessage}` },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as AnthropicResponse;
|
||||
|
||||
// Check if we got a valid response
|
||||
if (data.content && data.content.length > 0) {
|
||||
const textContent = data.content.find((block) => block.type === "text");
|
||||
if (textContent && textContent.type === "text" && textContent.text) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Connection successful! Response: "${textContent.text}"`,
|
||||
model: data.model,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Connection successful! Claude responded.",
|
||||
model: data.model,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error("Claude API test error:", error);
|
||||
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to connect to Claude API";
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: errorMessage },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
interface GeminiContent {
|
||||
parts: Array<{
|
||||
text?: string;
|
||||
inlineData?: {
|
||||
mimeType: string;
|
||||
data: string;
|
||||
};
|
||||
}>;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
interface GeminiRequest {
|
||||
contents: GeminiContent[];
|
||||
generationConfig?: {
|
||||
maxOutputTokens?: number;
|
||||
temperature?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface GeminiResponse {
|
||||
candidates?: Array<{
|
||||
content: {
|
||||
parts: Array<{
|
||||
text: string;
|
||||
}>;
|
||||
role: string;
|
||||
};
|
||||
finishReason: string;
|
||||
safetyRatings?: Array<{
|
||||
category: string;
|
||||
probability: string;
|
||||
}>;
|
||||
}>;
|
||||
promptFeedback?: {
|
||||
safetyRatings?: Array<{
|
||||
category: string;
|
||||
probability: string;
|
||||
}>;
|
||||
};
|
||||
error?: {
|
||||
code: number;
|
||||
message: string;
|
||||
status: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { apiKey, imageData, mimeType, prompt } = await request.json();
|
||||
|
||||
// Use provided API key or fall back to environment variable
|
||||
const effectiveApiKey = apiKey || process.env.GOOGLE_API_KEY;
|
||||
|
||||
if (!effectiveApiKey) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "No API key provided or configured in environment" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Build the request body
|
||||
const requestBody: GeminiRequest = {
|
||||
contents: [
|
||||
{
|
||||
parts: [],
|
||||
},
|
||||
],
|
||||
generationConfig: {
|
||||
maxOutputTokens: 150,
|
||||
temperature: 0.4,
|
||||
},
|
||||
};
|
||||
|
||||
// Add image if provided
|
||||
if (imageData && mimeType) {
|
||||
requestBody.contents[0].parts.push({
|
||||
inlineData: {
|
||||
mimeType: mimeType,
|
||||
data: imageData,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Add text prompt
|
||||
const textPrompt = prompt || (imageData
|
||||
? "Describe what you see in this image briefly."
|
||||
: "Respond with exactly: 'Gemini SDK connection successful!' and nothing else.");
|
||||
|
||||
requestBody.contents[0].parts.push({
|
||||
text: textPrompt,
|
||||
});
|
||||
|
||||
// Call Gemini API - using gemini-1.5-flash as it supports both text and vision
|
||||
const model = imageData ? "gemini-1.5-flash" : "gemini-1.5-flash";
|
||||
const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${effectiveApiKey}`;
|
||||
|
||||
const response = await fetch(geminiUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
const data: GeminiResponse = await response.json();
|
||||
|
||||
// Check for API errors
|
||||
if (data.error) {
|
||||
const errorMessage = data.error.message || "Unknown Gemini API error";
|
||||
const statusCode = data.error.code || 500;
|
||||
|
||||
if (statusCode === 400 && errorMessage.includes("API key")) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Invalid API key. Please check your Google API key." },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
if (statusCode === 429) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Rate limit exceeded. Please try again later." },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `API error: ${errorMessage}` },
|
||||
{ status: statusCode }
|
||||
);
|
||||
}
|
||||
|
||||
// Check for valid response
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `HTTP error: ${response.status} ${response.statusText}` },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
// Extract response text
|
||||
if (data.candidates && data.candidates.length > 0 && data.candidates[0].content?.parts?.length > 0) {
|
||||
const responseText = data.candidates[0].content.parts
|
||||
.filter((part) => part.text)
|
||||
.map((part) => part.text)
|
||||
.join("");
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Connection successful! Response: "${responseText.substring(0, 200)}${responseText.length > 200 ? '...' : ''}"`,
|
||||
model: model,
|
||||
hasImage: !!imageData,
|
||||
});
|
||||
}
|
||||
|
||||
// Handle blocked responses
|
||||
if (data.promptFeedback?.safetyRatings) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Connection successful! Gemini responded (response may have been filtered).",
|
||||
model: model,
|
||||
hasImage: !!imageData,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Connection successful! Gemini responded.",
|
||||
model: model,
|
||||
hasImage: !!imageData,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error("Gemini API test error:", error);
|
||||
|
||||
if (error instanceof TypeError && error.message.includes("fetch")) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Network error. Unable to reach Gemini API." },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to connect to Gemini API";
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: errorMessage },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
@@ -1,26 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
import { GeistSans } from "geist/font/sans";
|
||||
import { GeistMono } from "geist/font/mono";
|
||||
import { Toaster } from "sonner";
|
||||
import "./globals.css";
|
||||
export const metadata: Metadata = {
|
||||
title: "Automaker - Autonomous AI Development Studio",
|
||||
description: "Build software autonomously with intelligent orchestration",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={`${GeistSans.variable} ${GeistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
<Toaster richColors position="bottom-right" />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
import { WelcomeView } from "@/components/views/welcome-view";
|
||||
import { BoardView } from "@/components/views/board-view";
|
||||
import { SpecView } from "@/components/views/spec-view";
|
||||
import { AgentView } from "@/components/views/agent-view";
|
||||
import { SettingsView } from "@/components/views/settings-view";
|
||||
import { InterviewView } from "@/components/views/interview-view";
|
||||
import { ContextView } from "@/components/views/context-view";
|
||||
import { ProfilesView } from "@/components/views/profiles-view";
|
||||
import { SetupView } from "@/components/views/setup-view";
|
||||
import { RunningAgentsView } from "@/components/views/running-agents-view";
|
||||
import { TerminalView } from "@/components/views/terminal-view";
|
||||
import { WikiView } from "@/components/views/wiki-view";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { useSetupStore } from "@/store/setup-store";
|
||||
import { getElectronAPI, isElectron } from "@/lib/electron";
|
||||
import {
|
||||
FileBrowserProvider,
|
||||
useFileBrowser,
|
||||
setGlobalFileBrowser,
|
||||
} from "@/contexts/file-browser-context";
|
||||
|
||||
function HomeContent() {
|
||||
const {
|
||||
currentView,
|
||||
setCurrentView,
|
||||
setIpcConnected,
|
||||
theme,
|
||||
currentProject,
|
||||
previewTheme,
|
||||
getEffectiveTheme,
|
||||
} = useAppStore();
|
||||
const { isFirstRun, setupComplete } = useSetupStore();
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [streamerPanelOpen, setStreamerPanelOpen] = useState(false);
|
||||
const { openFileBrowser } = useFileBrowser();
|
||||
|
||||
// Hidden streamer panel - opens with "\" key
|
||||
const handleStreamerPanelShortcut = useCallback((event: KeyboardEvent) => {
|
||||
// Don't trigger when typing in inputs
|
||||
const activeElement = document.activeElement;
|
||||
if (activeElement) {
|
||||
const tagName = activeElement.tagName.toLowerCase();
|
||||
if (
|
||||
tagName === "input" ||
|
||||
tagName === "textarea" ||
|
||||
tagName === "select"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (activeElement.getAttribute("contenteditable") === "true") {
|
||||
return;
|
||||
}
|
||||
const role = activeElement.getAttribute("role");
|
||||
if (role === "textbox" || role === "searchbox" || role === "combobox") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Don't trigger with modifier keys
|
||||
if (event.ctrlKey || event.altKey || event.metaKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for "\" key (backslash)
|
||||
if (event.key === "\\") {
|
||||
event.preventDefault();
|
||||
setStreamerPanelOpen((prev) => !prev);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Register the "\" shortcut for streamer panel
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", handleStreamerPanelShortcut);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleStreamerPanelShortcut);
|
||||
};
|
||||
}, [handleStreamerPanelShortcut]);
|
||||
|
||||
// Compute the effective theme: previewTheme takes priority, then project theme, then global theme
|
||||
// This is reactive because it depends on previewTheme, currentProject, and theme from the store
|
||||
const effectiveTheme = getEffectiveTheme();
|
||||
|
||||
// Prevent hydration issues
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
// Initialize global file browser for HttpApiClient
|
||||
useEffect(() => {
|
||||
setGlobalFileBrowser(openFileBrowser);
|
||||
}, [openFileBrowser]);
|
||||
|
||||
// Check if this is first run and redirect to setup if needed
|
||||
useEffect(() => {
|
||||
console.log("[Setup Flow] Checking setup state:", {
|
||||
isMounted,
|
||||
isFirstRun,
|
||||
setupComplete,
|
||||
currentView,
|
||||
shouldShowSetup: isMounted && isFirstRun && !setupComplete,
|
||||
});
|
||||
|
||||
if (isMounted && isFirstRun && !setupComplete) {
|
||||
console.log(
|
||||
"[Setup Flow] Redirecting to setup wizard (first run, not complete)"
|
||||
);
|
||||
setCurrentView("setup");
|
||||
} else if (isMounted && setupComplete) {
|
||||
console.log("[Setup Flow] Setup already complete, showing normal view");
|
||||
}
|
||||
}, [isMounted, isFirstRun, setupComplete, setCurrentView, currentView]);
|
||||
|
||||
// Test IPC connection on mount
|
||||
useEffect(() => {
|
||||
const testConnection = async () => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const result = await api.ping();
|
||||
setIpcConnected(result === "pong");
|
||||
} catch (error) {
|
||||
console.error("IPC connection failed:", error);
|
||||
setIpcConnected(false);
|
||||
}
|
||||
};
|
||||
|
||||
testConnection();
|
||||
}, [setIpcConnected]);
|
||||
|
||||
// Apply theme class to document (uses effective theme - preview, project-specific, or global)
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
root.classList.remove(
|
||||
"dark",
|
||||
"retro",
|
||||
"light",
|
||||
"dracula",
|
||||
"nord",
|
||||
"monokai",
|
||||
"tokyonight",
|
||||
"solarized",
|
||||
"gruvbox",
|
||||
"catppuccin",
|
||||
"onedark",
|
||||
"synthwave",
|
||||
"red"
|
||||
);
|
||||
|
||||
if (effectiveTheme === "dark") {
|
||||
root.classList.add("dark");
|
||||
} else if (effectiveTheme === "retro") {
|
||||
root.classList.add("retro");
|
||||
} else if (effectiveTheme === "dracula") {
|
||||
root.classList.add("dracula");
|
||||
} else if (effectiveTheme === "nord") {
|
||||
root.classList.add("nord");
|
||||
} else if (effectiveTheme === "monokai") {
|
||||
root.classList.add("monokai");
|
||||
} else if (effectiveTheme === "tokyonight") {
|
||||
root.classList.add("tokyonight");
|
||||
} else if (effectiveTheme === "solarized") {
|
||||
root.classList.add("solarized");
|
||||
} else if (effectiveTheme === "gruvbox") {
|
||||
root.classList.add("gruvbox");
|
||||
} else if (effectiveTheme === "catppuccin") {
|
||||
root.classList.add("catppuccin");
|
||||
} else if (effectiveTheme === "onedark") {
|
||||
root.classList.add("onedark");
|
||||
} else if (effectiveTheme === "synthwave") {
|
||||
root.classList.add("synthwave");
|
||||
} else if (effectiveTheme === "red") {
|
||||
root.classList.add("red");
|
||||
} else if (effectiveTheme === "light") {
|
||||
root.classList.add("light");
|
||||
} else if (effectiveTheme === "system") {
|
||||
// System theme
|
||||
const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
if (isDark) {
|
||||
root.classList.add("dark");
|
||||
} else {
|
||||
root.classList.add("light");
|
||||
}
|
||||
}
|
||||
}, [effectiveTheme, previewTheme, currentProject, theme]);
|
||||
|
||||
const renderView = () => {
|
||||
switch (currentView) {
|
||||
case "welcome":
|
||||
return <WelcomeView />;
|
||||
case "setup":
|
||||
return <SetupView />;
|
||||
case "board":
|
||||
return <BoardView />;
|
||||
case "spec":
|
||||
return <SpecView />;
|
||||
case "agent":
|
||||
return <AgentView />;
|
||||
case "settings":
|
||||
return <SettingsView />;
|
||||
case "interview":
|
||||
return <InterviewView />;
|
||||
case "context":
|
||||
return <ContextView />;
|
||||
case "profiles":
|
||||
return <ProfilesView />;
|
||||
case "running-agents":
|
||||
return <RunningAgentsView />;
|
||||
case "terminal":
|
||||
return <TerminalView />;
|
||||
case "wiki":
|
||||
return <WikiView />;
|
||||
default:
|
||||
return <WelcomeView />;
|
||||
}
|
||||
};
|
||||
|
||||
// Setup view is full-screen without sidebar
|
||||
if (currentView === "setup") {
|
||||
return (
|
||||
<main className="h-screen overflow-hidden" data-testid="app-container">
|
||||
<SetupView />
|
||||
{/* Environment indicator */}
|
||||
{isMounted && !isElectron() && (
|
||||
<div className="fixed bottom-4 right-4 px-3 py-1.5 bg-blue-500/10 text-blue-500 text-xs rounded-full border border-blue-500/20 pointer-events-none">
|
||||
Web Mode
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex h-screen overflow-hidden" data-testid="app-container">
|
||||
<Sidebar />
|
||||
<div
|
||||
className="flex-1 flex flex-col overflow-hidden transition-all duration-300"
|
||||
style={{ marginRight: streamerPanelOpen ? "250px" : "0" }}
|
||||
>
|
||||
{renderView()}
|
||||
</div>
|
||||
|
||||
{/* Environment indicator - only show after mount to prevent hydration issues */}
|
||||
{isMounted && !isElectron() && (
|
||||
<div className="fixed bottom-4 right-4 px-3 py-1.5 bg-blue-500/10 text-blue-500 text-xs rounded-full border border-blue-500/20 pointer-events-none">
|
||||
Web Mode
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hidden streamer panel - opens with "\" key, pushes content */}
|
||||
<div
|
||||
className={`fixed top-0 right-0 h-full w-[250px] bg-background border-l border-border transition-transform duration-300 ${
|
||||
streamerPanelOpen ? "translate-x-0" : "translate-x-full"
|
||||
}`}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<FileBrowserProvider>
|
||||
<HomeContent />
|
||||
</FileBrowserProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { FolderOpen, Folder, ChevronRight, Home, ArrowLeft, HardDrive } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface DirectoryEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface BrowseResult {
|
||||
success: boolean;
|
||||
currentPath: string;
|
||||
parentPath: string | null;
|
||||
directories: DirectoryEntry[];
|
||||
drives?: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface FileBrowserDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSelect: (path: string) => void;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function FileBrowserDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSelect,
|
||||
title = "Select Project Directory",
|
||||
description = "Navigate to your project folder",
|
||||
}: FileBrowserDialogProps) {
|
||||
const [currentPath, setCurrentPath] = useState<string>("");
|
||||
const [parentPath, setParentPath] = useState<string | null>(null);
|
||||
const [directories, setDirectories] = useState<DirectoryEntry[]>([]);
|
||||
const [drives, setDrives] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const browseDirectory = async (dirPath?: string) => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
// Get server URL from environment or default
|
||||
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008";
|
||||
|
||||
const response = await fetch(`${serverUrl}/api/fs/browse`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ dirPath }),
|
||||
});
|
||||
|
||||
const result: BrowseResult = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
setCurrentPath(result.currentPath);
|
||||
setParentPath(result.parentPath);
|
||||
setDirectories(result.directories);
|
||||
setDrives(result.drives || []);
|
||||
} else {
|
||||
setError(result.error || "Failed to browse directory");
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load directories");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load home directory on mount
|
||||
useEffect(() => {
|
||||
if (open && !currentPath) {
|
||||
browseDirectory();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleSelectDirectory = (dir: DirectoryEntry) => {
|
||||
browseDirectory(dir.path);
|
||||
};
|
||||
|
||||
const handleGoToParent = () => {
|
||||
if (parentPath) {
|
||||
browseDirectory(parentPath);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoHome = () => {
|
||||
browseDirectory();
|
||||
};
|
||||
|
||||
const handleSelectDrive = (drivePath: string) => {
|
||||
browseDirectory(drivePath);
|
||||
};
|
||||
|
||||
const handleSelect = () => {
|
||||
if (currentPath) {
|
||||
onSelect(currentPath);
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="bg-popover border-border max-w-2xl max-h-[80vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FolderOpen className="w-5 h-5 text-brand-500" />
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
{description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-3 min-h-[400px]">
|
||||
{/* Drives selector (Windows only) */}
|
||||
{drives.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 p-3 rounded-lg bg-sidebar-accent/10 border border-sidebar-border">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground mr-2">
|
||||
<HardDrive className="w-3 h-3" />
|
||||
<span>Drives:</span>
|
||||
</div>
|
||||
{drives.map((drive) => (
|
||||
<Button
|
||||
key={drive}
|
||||
variant={currentPath.startsWith(drive) ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => handleSelectDrive(drive)}
|
||||
className="h-7 px-3 text-xs"
|
||||
disabled={loading}
|
||||
>
|
||||
{drive.replace("\\", "")}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current path breadcrumb */}
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-sidebar-accent/10 border border-sidebar-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleGoHome}
|
||||
className="h-7 px-2"
|
||||
disabled={loading}
|
||||
>
|
||||
<Home className="w-4 h-4" />
|
||||
</Button>
|
||||
{parentPath && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleGoToParent}
|
||||
className="h-7 px-2"
|
||||
disabled={loading}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex-1 font-mono text-sm truncate text-muted-foreground">
|
||||
{currentPath || "Loading..."}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Directory list */}
|
||||
<div className="flex-1 overflow-y-auto border border-sidebar-border rounded-lg">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center h-full p-8">
|
||||
<div className="text-sm text-muted-foreground">Loading directories...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center justify-center h-full p-8">
|
||||
<div className="text-sm text-destructive">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && directories.length === 0 && (
|
||||
<div className="flex items-center justify-center h-full p-8">
|
||||
<div className="text-sm text-muted-foreground">No subdirectories found</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && directories.length > 0 && (
|
||||
<div className="divide-y divide-sidebar-border">
|
||||
{directories.map((dir) => (
|
||||
<button
|
||||
key={dir.path}
|
||||
onClick={() => handleSelectDirectory(dir)}
|
||||
className="w-full flex items-center gap-3 p-3 hover:bg-sidebar-accent/10 transition-colors text-left group"
|
||||
>
|
||||
<Folder className="w-5 h-5 text-brand-500 shrink-0" />
|
||||
<span className="flex-1 truncate text-sm">{dir.name}</span>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Click on a folder to navigate. Select the current folder or navigate to a subfolder.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSelect} disabled={!currentPath || loading}>
|
||||
<FolderOpen className="w-4 h-4 mr-2" />
|
||||
Select Current Folder
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,36 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -1,91 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
|
||||
interface CategoryAutocompleteProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
suggestions: string[];
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
"data-testid"?: string;
|
||||
}
|
||||
|
||||
export function CategoryAutocomplete({
|
||||
value,
|
||||
onChange,
|
||||
suggestions,
|
||||
placeholder = "Select or type a category...",
|
||||
className,
|
||||
disabled = false,
|
||||
"data-testid": testId,
|
||||
}: CategoryAutocompleteProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
className={cn("w-full justify-between", className)}
|
||||
data-testid={testId}
|
||||
>
|
||||
{value
|
||||
? suggestions.find((s) => s === value) ?? value
|
||||
: placeholder}
|
||||
<ChevronsUpDown className="opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search category..." className="h-9" />
|
||||
<CommandList>
|
||||
<CommandEmpty>No category found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{suggestions.map((suggestion) => (
|
||||
<CommandItem
|
||||
key={suggestion}
|
||||
value={suggestion}
|
||||
onSelect={(currentValue) => {
|
||||
onChange(currentValue === value ? "" : currentValue);
|
||||
setOpen(false);
|
||||
}}
|
||||
data-testid={`category-option-${suggestion.toLowerCase().replace(/\s+/g, "-")}`}
|
||||
>
|
||||
{suggestion}
|
||||
<Check
|
||||
className={cn(
|
||||
"ml-auto",
|
||||
value === suggestion ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground hover:border-primary/80",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("flex items-center justify-center text-current")}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
));
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||
|
||||
export { Checkbox };
|
||||
@@ -1,88 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Sparkles, X } from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
interface CoursePromoBadgeProps {
|
||||
sidebarOpen?: boolean;
|
||||
}
|
||||
|
||||
export function CoursePromoBadge({ sidebarOpen = true }: CoursePromoBadgeProps) {
|
||||
const [dismissed, setDismissed] = React.useState(false);
|
||||
|
||||
if (dismissed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Collapsed state - show only icon with tooltip
|
||||
if (!sidebarOpen) {
|
||||
return (
|
||||
<div className="p-2 pb-0 flex justify-center">
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href="https://agenticjumpstart.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group cursor-pointer flex items-center justify-center w-10 h-10 bg-primary/10 text-primary rounded-lg hover:bg-primary/20 transition-all border border-primary/30"
|
||||
data-testid="course-promo-badge-collapsed"
|
||||
>
|
||||
<Sparkles className="size-4 shrink-0" />
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="flex items-center gap-2">
|
||||
<span>Become a 10x Dev</span>
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDismissed(true);
|
||||
}}
|
||||
className="p-0.5 rounded-full hover:bg-primary/30 transition-colors cursor-pointer"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Expanded state - show full badge
|
||||
return (
|
||||
<div className="p-2 pb-0">
|
||||
<a
|
||||
href="https://agenticjumpstart.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group cursor-pointer flex items-center justify-between w-full px-2 lg:px-3 py-2.5 bg-primary/10 text-primary rounded-lg font-medium text-sm hover:bg-primary/20 transition-all border border-primary/30"
|
||||
data-testid="course-promo-badge"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="size-4 shrink-0" />
|
||||
<span className="hidden lg:block">Become a 10x Dev</span>
|
||||
</div>
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDismissed(true);
|
||||
}}
|
||||
className="hidden lg:block p-1 rounded-full hover:bg-primary/30 transition-colors cursor-pointer"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { XIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
compact = false,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
// Check if className contains a custom max-width
|
||||
const hasCustomMaxWidth =
|
||||
typeof className === "string" && className.includes("max-w-");
|
||||
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 flex flex-col w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] rounded-lg border shadow-lg duration-200 max-h-[calc(100vh-4rem)]",
|
||||
compact
|
||||
? "max-w-4xl p-4"
|
||||
: !hasCustomMaxWidth
|
||||
? "sm:max-w-2xl p-6"
|
||||
: "p-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className={cn(
|
||||
"ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute rounded-xs opacity-70 transition-opacity cursor-pointer hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
compact ? "top-2 right-3" : "top-3 right-5"
|
||||
)}
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground bg-input border-input h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -1,284 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
MessageSquare,
|
||||
Wrench,
|
||||
Zap,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
Bug,
|
||||
Info,
|
||||
FileOutput,
|
||||
Brain,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
parseLogOutput,
|
||||
getLogTypeColors,
|
||||
type LogEntry,
|
||||
type LogEntryType,
|
||||
} from "@/lib/log-parser";
|
||||
|
||||
interface LogViewerProps {
|
||||
output: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const getLogIcon = (type: LogEntryType) => {
|
||||
switch (type) {
|
||||
case "prompt":
|
||||
return <MessageSquare className="w-4 h-4" />;
|
||||
case "tool_call":
|
||||
return <Wrench className="w-4 h-4" />;
|
||||
case "tool_result":
|
||||
return <FileOutput className="w-4 h-4" />;
|
||||
case "phase":
|
||||
return <Zap className="w-4 h-4" />;
|
||||
case "error":
|
||||
return <AlertCircle className="w-4 h-4" />;
|
||||
case "success":
|
||||
return <CheckCircle2 className="w-4 h-4" />;
|
||||
case "warning":
|
||||
return <AlertTriangle className="w-4 h-4" />;
|
||||
case "thinking":
|
||||
return <Brain className="w-4 h-4" />;
|
||||
case "debug":
|
||||
return <Bug className="w-4 h-4" />;
|
||||
default:
|
||||
return <Info className="w-4 h-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
interface LogEntryItemProps {
|
||||
entry: LogEntry;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
|
||||
const colors = getLogTypeColors(entry.type);
|
||||
const hasContent = entry.content.length > 100;
|
||||
|
||||
// Format content - detect and highlight JSON
|
||||
const formattedContent = useMemo(() => {
|
||||
const content = entry.content;
|
||||
|
||||
// Try to find and format JSON blocks
|
||||
const jsonRegex = /(\{[\s\S]*?\}|\[[\s\S]*?\])/g;
|
||||
let lastIndex = 0;
|
||||
const parts: { type: "text" | "json"; content: string }[] = [];
|
||||
|
||||
let match;
|
||||
while ((match = jsonRegex.exec(content)) !== null) {
|
||||
// Add text before JSON
|
||||
if (match.index > lastIndex) {
|
||||
parts.push({
|
||||
type: "text",
|
||||
content: content.slice(lastIndex, match.index),
|
||||
});
|
||||
}
|
||||
|
||||
// Try to parse and format JSON
|
||||
try {
|
||||
const parsed = JSON.parse(match[1]);
|
||||
parts.push({
|
||||
type: "json",
|
||||
content: JSON.stringify(parsed, null, 2),
|
||||
});
|
||||
} catch {
|
||||
// Not valid JSON, treat as text
|
||||
parts.push({ type: "text", content: match[1] });
|
||||
}
|
||||
|
||||
lastIndex = match.index + match[1].length;
|
||||
}
|
||||
|
||||
// Add remaining text
|
||||
if (lastIndex < content.length) {
|
||||
parts.push({ type: "text", content: content.slice(lastIndex) });
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts : [{ type: "text" as const, content }];
|
||||
}, [entry.content]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border-l-4 transition-all duration-200",
|
||||
colors.bg,
|
||||
colors.border,
|
||||
"hover:brightness-110"
|
||||
)}
|
||||
data-testid={`log-entry-${entry.type}`}
|
||||
>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="w-full px-3 py-2 flex items-center gap-2 text-left"
|
||||
data-testid={`log-entry-toggle-${entry.id}`}
|
||||
>
|
||||
{hasContent ? (
|
||||
isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-zinc-400 flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-zinc-400 flex-shrink-0" />
|
||||
)
|
||||
) : (
|
||||
<span className="w-4 flex-shrink-0" />
|
||||
)}
|
||||
|
||||
<span className={cn("flex-shrink-0", colors.icon)}>
|
||||
{getLogIcon(entry.type)}
|
||||
</span>
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-medium px-2 py-0.5 rounded-full flex-shrink-0",
|
||||
colors.badge
|
||||
)}
|
||||
data-testid="log-entry-badge"
|
||||
>
|
||||
{entry.title}
|
||||
</span>
|
||||
|
||||
<span className="text-xs text-zinc-400 truncate flex-1 ml-2">
|
||||
{!isExpanded &&
|
||||
entry.content.slice(0, 80) +
|
||||
(entry.content.length > 80 ? "..." : "")}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{(isExpanded || !hasContent) && (
|
||||
<div
|
||||
className="px-4 pb-3 pt-1"
|
||||
data-testid={`log-entry-content-${entry.id}`}
|
||||
>
|
||||
<div className="font-mono text-xs space-y-1">
|
||||
{formattedContent.map((part, index) => (
|
||||
<div key={index}>
|
||||
{part.type === "json" ? (
|
||||
<pre className="bg-zinc-900/50 rounded p-2 overflow-x-auto text-xs text-primary">
|
||||
{part.content}
|
||||
</pre>
|
||||
) : (
|
||||
<pre
|
||||
className={cn(
|
||||
"whitespace-pre-wrap break-words",
|
||||
colors.text
|
||||
)}
|
||||
>
|
||||
{part.content}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LogViewer({ output, className }: LogViewerProps) {
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const entries = useMemo(() => parseLogOutput(output), [output]);
|
||||
|
||||
const toggleEntry = (id: string) => {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const expandAll = () => {
|
||||
setExpandedIds(new Set(entries.map((e) => e.id)));
|
||||
};
|
||||
|
||||
const collapseAll = () => {
|
||||
setExpandedIds(new Set());
|
||||
};
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8 text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<Info className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">No log entries yet. Logs will appear here as the process runs.</p>
|
||||
{output && output.trim() && (
|
||||
<div className="mt-4 p-3 bg-zinc-900/50 rounded text-xs font-mono text-left max-h-40 overflow-auto">
|
||||
<pre className="whitespace-pre-wrap">{output}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Count entries by type
|
||||
const typeCounts = entries.reduce((acc, entry) => {
|
||||
acc[entry.type] = (acc[entry.type] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-2", className)}>
|
||||
{/* Header with controls */}
|
||||
<div className="flex items-center justify-between px-1" data-testid="log-viewer-header">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{Object.entries(typeCounts).map(([type, count]) => {
|
||||
const colors = getLogTypeColors(type as LogEntryType);
|
||||
return (
|
||||
<span
|
||||
key={type}
|
||||
className={cn(
|
||||
"text-xs px-2 py-0.5 rounded-full",
|
||||
colors.badge
|
||||
)}
|
||||
data-testid={`log-type-count-${type}`}
|
||||
>
|
||||
{type}: {count}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={expandAll}
|
||||
className="text-xs text-zinc-400 hover:text-zinc-200 px-2 py-1 rounded hover:bg-zinc-800/50 transition-colors"
|
||||
data-testid="log-expand-all"
|
||||
>
|
||||
Expand All
|
||||
</button>
|
||||
<button
|
||||
onClick={collapseAll}
|
||||
className="text-xs text-zinc-400 hover:text-zinc-200 px-2 py-1 rounded hover:bg-zinc-800/50 transition-colors"
|
||||
data-testid="log-collapse-all"
|
||||
>
|
||||
Collapse All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Log entries */}
|
||||
<div className="space-y-2" data-testid="log-entries-container">
|
||||
{entries.map((entry) => (
|
||||
<LogEntryItem
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
isExpanded={expandedIds.has(entry.id)}
|
||||
onToggle={() => toggleEntry(entry.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Slider = React.forwardRef<
|
||||
React.ComponentRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none select-none items-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="slider-track relative h-1.5 w-full grow overflow-hidden rounded-full bg-muted cursor-pointer">
|
||||
<SliderPrimitive.Range className="slider-range absolute h-full bg-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="slider-thumb block h-4 w-4 rounded-full border border-border bg-card shadow transition-colors cursor-grab active:cursor-grabbing focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed hover:bg-accent" />
|
||||
</SliderPrimitive.Root>
|
||||
));
|
||||
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||
|
||||
export { Slider };
|
||||
@@ -1,71 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px] border border-border",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all duration-200 cursor-pointer",
|
||||
"text-foreground/70 hover:text-foreground hover:bg-accent",
|
||||
"data-[state=active]:bg-primary data-[state=active]:text-primary-foreground data-[state=active]:shadow-md data-[state=active]:border-primary/50",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring focus-visible:ring-[3px] focus-visible:outline-1",
|
||||
"disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
@@ -1,20 +0,0 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input min-h-[80px] w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm resize-none",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
@@ -1,32 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
@@ -1,290 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useCallback, useMemo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface XmlSyntaxEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
"data-testid"?: string;
|
||||
}
|
||||
|
||||
// Tokenize XML content into parts for highlighting
|
||||
interface Token {
|
||||
type:
|
||||
| "tag-bracket"
|
||||
| "tag-name"
|
||||
| "attribute-name"
|
||||
| "attribute-equals"
|
||||
| "attribute-value"
|
||||
| "text"
|
||||
| "comment"
|
||||
| "cdata"
|
||||
| "doctype";
|
||||
value: string;
|
||||
}
|
||||
|
||||
function tokenizeXml(text: string): Token[] {
|
||||
const tokens: Token[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < text.length) {
|
||||
// Comment: <!-- ... -->
|
||||
if (text.slice(i, i + 4) === "<!--") {
|
||||
const end = text.indexOf("-->", i + 4);
|
||||
if (end !== -1) {
|
||||
tokens.push({ type: "comment", value: text.slice(i, end + 3) });
|
||||
i = end + 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// CDATA: <![CDATA[ ... ]]>
|
||||
if (text.slice(i, i + 9) === "<![CDATA[") {
|
||||
const end = text.indexOf("]]>", i + 9);
|
||||
if (end !== -1) {
|
||||
tokens.push({ type: "cdata", value: text.slice(i, end + 3) });
|
||||
i = end + 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// DOCTYPE: <!DOCTYPE ... >
|
||||
if (text.slice(i, i + 9).toUpperCase() === "<!DOCTYPE") {
|
||||
const end = text.indexOf(">", i + 9);
|
||||
if (end !== -1) {
|
||||
tokens.push({ type: "doctype", value: text.slice(i, end + 1) });
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Tag: < ... >
|
||||
if (text[i] === "<") {
|
||||
// Find the end of the tag
|
||||
let tagEnd = i + 1;
|
||||
let inString: string | null = null;
|
||||
|
||||
while (tagEnd < text.length) {
|
||||
const char = text[tagEnd];
|
||||
|
||||
if (inString) {
|
||||
if (char === inString && text[tagEnd - 1] !== "\\") {
|
||||
inString = null;
|
||||
}
|
||||
} else {
|
||||
if (char === '"' || char === "'") {
|
||||
inString = char;
|
||||
} else if (char === ">") {
|
||||
tagEnd++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
tagEnd++;
|
||||
}
|
||||
|
||||
const tagContent = text.slice(i, tagEnd);
|
||||
const tagTokens = tokenizeTag(tagContent);
|
||||
tokens.push(...tagTokens);
|
||||
i = tagEnd;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Text content between tags
|
||||
const nextTag = text.indexOf("<", i);
|
||||
if (nextTag === -1) {
|
||||
tokens.push({ type: "text", value: text.slice(i) });
|
||||
break;
|
||||
} else if (nextTag > i) {
|
||||
tokens.push({ type: "text", value: text.slice(i, nextTag) });
|
||||
i = nextTag;
|
||||
}
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function tokenizeTag(tag: string): Token[] {
|
||||
const tokens: Token[] = [];
|
||||
let i = 0;
|
||||
|
||||
// Opening bracket (< or </ or <?)
|
||||
if (tag.startsWith("</")) {
|
||||
tokens.push({ type: "tag-bracket", value: "</" });
|
||||
i = 2;
|
||||
} else if (tag.startsWith("<?")) {
|
||||
tokens.push({ type: "tag-bracket", value: "<?" });
|
||||
i = 2;
|
||||
} else {
|
||||
tokens.push({ type: "tag-bracket", value: "<" });
|
||||
i = 1;
|
||||
}
|
||||
|
||||
// Skip whitespace
|
||||
while (i < tag.length && /\s/.test(tag[i])) {
|
||||
tokens.push({ type: "text", value: tag[i] });
|
||||
i++;
|
||||
}
|
||||
|
||||
// Tag name
|
||||
let tagName = "";
|
||||
while (i < tag.length && /[a-zA-Z0-9_:-]/.test(tag[i])) {
|
||||
tagName += tag[i];
|
||||
i++;
|
||||
}
|
||||
if (tagName) {
|
||||
tokens.push({ type: "tag-name", value: tagName });
|
||||
}
|
||||
|
||||
// Attributes and closing
|
||||
while (i < tag.length) {
|
||||
// Skip whitespace
|
||||
if (/\s/.test(tag[i])) {
|
||||
let ws = "";
|
||||
while (i < tag.length && /\s/.test(tag[i])) {
|
||||
ws += tag[i];
|
||||
i++;
|
||||
}
|
||||
tokens.push({ type: "text", value: ws });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Closing bracket
|
||||
if (tag[i] === ">" || tag.slice(i, i + 2) === "/>" || tag.slice(i, i + 2) === "?>") {
|
||||
tokens.push({ type: "tag-bracket", value: tag.slice(i) });
|
||||
break;
|
||||
}
|
||||
|
||||
// Attribute name
|
||||
let attrName = "";
|
||||
while (i < tag.length && /[a-zA-Z0-9_:-]/.test(tag[i])) {
|
||||
attrName += tag[i];
|
||||
i++;
|
||||
}
|
||||
if (attrName) {
|
||||
tokens.push({ type: "attribute-name", value: attrName });
|
||||
}
|
||||
|
||||
// Skip whitespace around =
|
||||
while (i < tag.length && /\s/.test(tag[i])) {
|
||||
tokens.push({ type: "text", value: tag[i] });
|
||||
i++;
|
||||
}
|
||||
|
||||
// Equals sign
|
||||
if (tag[i] === "=") {
|
||||
tokens.push({ type: "attribute-equals", value: "=" });
|
||||
i++;
|
||||
}
|
||||
|
||||
// Skip whitespace after =
|
||||
while (i < tag.length && /\s/.test(tag[i])) {
|
||||
tokens.push({ type: "text", value: tag[i] });
|
||||
i++;
|
||||
}
|
||||
|
||||
// Attribute value
|
||||
if (tag[i] === '"' || tag[i] === "'") {
|
||||
const quote = tag[i];
|
||||
let value = quote;
|
||||
i++;
|
||||
while (i < tag.length && tag[i] !== quote) {
|
||||
value += tag[i];
|
||||
i++;
|
||||
}
|
||||
if (i < tag.length) {
|
||||
value += tag[i];
|
||||
i++;
|
||||
}
|
||||
tokens.push({ type: "attribute-value", value });
|
||||
}
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
export function XmlSyntaxEditor({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
className,
|
||||
"data-testid": testId,
|
||||
}: XmlSyntaxEditorProps) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const highlightRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Sync scroll between textarea and highlight layer
|
||||
const handleScroll = useCallback(() => {
|
||||
if (textareaRef.current && highlightRef.current) {
|
||||
highlightRef.current.scrollTop = textareaRef.current.scrollTop;
|
||||
highlightRef.current.scrollLeft = textareaRef.current.scrollLeft;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle tab key for indentation
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
const textarea = e.currentTarget;
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const newValue =
|
||||
value.substring(0, start) + " " + value.substring(end);
|
||||
onChange(newValue);
|
||||
// Reset cursor position after state update
|
||||
requestAnimationFrame(() => {
|
||||
textarea.selectionStart = textarea.selectionEnd = start + 2;
|
||||
});
|
||||
}
|
||||
},
|
||||
[value, onChange]
|
||||
);
|
||||
|
||||
// Memoize the highlighted content
|
||||
const highlightedContent = useMemo(() => {
|
||||
const tokens = tokenizeXml(value);
|
||||
|
||||
return tokens.map((token, index) => {
|
||||
const className = `xml-${token.type}`;
|
||||
// React handles escaping automatically, just render the raw value
|
||||
return (
|
||||
<span key={index} className={className}>
|
||||
{token.value}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div className={cn("relative w-full h-full xml-editor", className)}>
|
||||
{/* Syntax highlighted layer (read-only, behind textarea) */}
|
||||
<div
|
||||
ref={highlightRef}
|
||||
className="absolute inset-0 overflow-auto pointer-events-none font-mono text-sm p-4 whitespace-pre-wrap break-words"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{value ? (
|
||||
<code className="xml-highlight">{highlightedContent}</code>
|
||||
) : (
|
||||
<span className="text-muted-foreground opacity-50">{placeholder}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actual textarea (transparent text, handles input) */}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onScroll={handleScroll}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder=""
|
||||
spellCheck={false}
|
||||
className="absolute inset-0 w-full h-full font-mono text-sm p-4 bg-transparent resize-none focus:outline-none text-transparent caret-foreground selection:bg-primary/30"
|
||||
data-testid={testId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,78 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { useDroppable } from "@dnd-kit/core";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface KanbanColumnProps {
|
||||
id: string;
|
||||
title: string;
|
||||
color: string;
|
||||
count: number;
|
||||
children: ReactNode;
|
||||
headerAction?: ReactNode;
|
||||
opacity?: number; // Opacity percentage (0-100) - only affects background
|
||||
showBorder?: boolean; // Whether to show column border
|
||||
hideScrollbar?: boolean; // Whether to hide the column scrollbar
|
||||
}
|
||||
|
||||
export const KanbanColumn = memo(function KanbanColumn({
|
||||
id,
|
||||
title,
|
||||
color,
|
||||
count,
|
||||
children,
|
||||
headerAction,
|
||||
opacity = 100,
|
||||
showBorder = true,
|
||||
hideScrollbar = false,
|
||||
}: KanbanColumnProps) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id });
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
"relative flex flex-col h-full rounded-lg transition-colors w-72",
|
||||
showBorder && "border border-border"
|
||||
)}
|
||||
data-testid={`kanban-column-${id}`}
|
||||
>
|
||||
{/* Background layer with opacity - only this layer is affected by opacity */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 rounded-lg backdrop-blur-sm transition-colors",
|
||||
isOver ? "bg-accent" : "bg-card"
|
||||
)}
|
||||
style={{ opacity: opacity / 100 }}
|
||||
/>
|
||||
|
||||
{/* Column Header - positioned above the background */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-10 flex items-center gap-2 p-3",
|
||||
showBorder && "border-b border-border"
|
||||
)}
|
||||
>
|
||||
<div className={cn("w-3 h-3 rounded-full", color)} />
|
||||
<h3 className="font-medium text-sm flex-1">{title}</h3>
|
||||
{headerAction}
|
||||
<span className="text-xs text-muted-foreground bg-background px-2 py-0.5 rounded-full">
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Column Content - positioned above the background */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-10 flex-1 overflow-y-auto p-2 space-y-2",
|
||||
hideScrollbar &&
|
||||
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,718 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from "react";
|
||||
import {
|
||||
useAppStore,
|
||||
AIProfile,
|
||||
AgentModel,
|
||||
ThinkingLevel,
|
||||
ModelProvider,
|
||||
} from "@/store/app-store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn, modelSupportsThinking } from "@/lib/utils";
|
||||
import {
|
||||
useKeyboardShortcuts,
|
||||
useKeyboardShortcutsConfig,
|
||||
KeyboardShortcut,
|
||||
} from "@/hooks/use-keyboard-shortcuts";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
UserCircle,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Brain,
|
||||
Zap,
|
||||
Scale,
|
||||
Cpu,
|
||||
Rocket,
|
||||
Sparkles,
|
||||
GripVertical,
|
||||
Lock,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
closestCenter,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
// Icon mapping for profiles
|
||||
const PROFILE_ICONS: Record<
|
||||
string,
|
||||
React.ComponentType<{ className?: string }>
|
||||
> = {
|
||||
Brain,
|
||||
Zap,
|
||||
Scale,
|
||||
Cpu,
|
||||
Rocket,
|
||||
Sparkles,
|
||||
};
|
||||
|
||||
// Available icons for selection
|
||||
const ICON_OPTIONS = [
|
||||
{ name: "Brain", icon: Brain },
|
||||
{ name: "Zap", icon: Zap },
|
||||
{ name: "Scale", icon: Scale },
|
||||
{ name: "Cpu", icon: Cpu },
|
||||
{ name: "Rocket", icon: Rocket },
|
||||
{ name: "Sparkles", icon: Sparkles },
|
||||
];
|
||||
|
||||
// Model options for the form
|
||||
const CLAUDE_MODELS: { id: AgentModel; label: string }[] = [
|
||||
{ id: "haiku", label: "Claude Haiku" },
|
||||
{ id: "sonnet", label: "Claude Sonnet" },
|
||||
{ id: "opus", label: "Claude Opus" },
|
||||
];
|
||||
|
||||
const CODEX_MODELS: { id: AgentModel; label: string }[] = [
|
||||
{ id: "gpt-5.1-codex-max", label: "GPT-5.1 Codex Max" },
|
||||
{ id: "gpt-5.1-codex", label: "GPT-5.1 Codex" },
|
||||
{ id: "gpt-5.1-codex-mini", label: "GPT-5.1 Codex Mini" },
|
||||
{ id: "gpt-5.1", label: "GPT-5.1" },
|
||||
];
|
||||
|
||||
const THINKING_LEVELS: { id: ThinkingLevel; label: string }[] = [
|
||||
{ id: "none", label: "None" },
|
||||
{ id: "low", label: "Low" },
|
||||
{ id: "medium", label: "Medium" },
|
||||
{ id: "high", label: "High" },
|
||||
{ id: "ultrathink", label: "Ultrathink" },
|
||||
];
|
||||
|
||||
// Helper to determine provider from model
|
||||
function getProviderFromModel(model: AgentModel): ModelProvider {
|
||||
if (model.startsWith("gpt")) {
|
||||
return "codex";
|
||||
}
|
||||
return "claude";
|
||||
}
|
||||
|
||||
// Sortable Profile Card Component
|
||||
function SortableProfileCard({
|
||||
profile,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
profile: AIProfile;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: profile.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
const IconComponent = profile.icon ? PROFILE_ICONS[profile.icon] : Brain;
|
||||
const isCodex = profile.provider === "codex";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
"group relative flex items-start gap-4 p-4 rounded-xl border bg-card transition-all",
|
||||
isDragging && "shadow-lg",
|
||||
profile.isBuiltIn
|
||||
? "border-border/50"
|
||||
: "border-border hover:border-primary/50 hover:shadow-sm"
|
||||
)}
|
||||
data-testid={`profile-card-${profile.id}`}
|
||||
>
|
||||
{/* Drag Handle */}
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="p-1 rounded hover:bg-accent cursor-grab active:cursor-grabbing flex-shrink-0 mt-1"
|
||||
data-testid={`profile-drag-handle-${profile.id}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
|
||||
{/* Icon */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center",
|
||||
isCodex ? "bg-emerald-500/10" : "bg-primary/10"
|
||||
)}
|
||||
>
|
||||
{IconComponent && (
|
||||
<IconComponent
|
||||
className={cn(
|
||||
"w-5 h-5",
|
||||
isCodex ? "text-emerald-500" : "text-primary"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-foreground">{profile.name}</h3>
|
||||
{profile.isBuiltIn && (
|
||||
<span className="flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full bg-muted text-muted-foreground">
|
||||
<Lock className="w-2.5 h-2.5" />
|
||||
Built-in
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-0.5 line-clamp-2">
|
||||
{profile.description}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-2 flex-wrap">
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs px-2 py-0.5 rounded-full border",
|
||||
isCodex
|
||||
? "border-emerald-500/30 text-emerald-600 dark:text-emerald-400 bg-emerald-500/10"
|
||||
: "border-primary/30 text-primary bg-primary/10"
|
||||
)}
|
||||
>
|
||||
{profile.model}
|
||||
</span>
|
||||
{profile.thinkingLevel !== "none" && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full border border-amber-500/30 text-amber-600 dark:text-amber-400 bg-amber-500/10">
|
||||
{profile.thinkingLevel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{!profile.isBuiltIn && (
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onEdit}
|
||||
className="h-8 w-8 p-0"
|
||||
data-testid={`edit-profile-${profile.id}`}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onDelete}
|
||||
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
|
||||
data-testid={`delete-profile-${profile.id}`}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Profile Form Component
|
||||
function ProfileForm({
|
||||
profile,
|
||||
onSave,
|
||||
onCancel,
|
||||
isEditing,
|
||||
hotkeyActive,
|
||||
}: {
|
||||
profile: Partial<AIProfile>;
|
||||
onSave: (profile: Omit<AIProfile, "id">) => void;
|
||||
onCancel: () => void;
|
||||
isEditing: boolean;
|
||||
hotkeyActive: boolean;
|
||||
}) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: profile.name || "",
|
||||
description: profile.description || "",
|
||||
model: profile.model || ("opus" as AgentModel),
|
||||
thinkingLevel: profile.thinkingLevel || ("none" as ThinkingLevel),
|
||||
icon: profile.icon || "Brain",
|
||||
});
|
||||
|
||||
const provider = getProviderFromModel(formData.model);
|
||||
const supportsThinking = modelSupportsThinking(formData.model);
|
||||
|
||||
const handleModelChange = (model: AgentModel) => {
|
||||
const newProvider = getProviderFromModel(model);
|
||||
setFormData({
|
||||
...formData,
|
||||
model,
|
||||
// Reset thinking level when switching to Codex (doesn't support thinking)
|
||||
thinkingLevel: newProvider === "codex" ? "none" : formData.thinkingLevel,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!formData.name.trim()) {
|
||||
toast.error("Please enter a profile name");
|
||||
return;
|
||||
}
|
||||
|
||||
onSave({
|
||||
name: formData.name.trim(),
|
||||
description: formData.description.trim(),
|
||||
model: formData.model,
|
||||
thinkingLevel: supportsThinking ? formData.thinkingLevel : "none",
|
||||
provider,
|
||||
isBuiltIn: false,
|
||||
icon: formData.icon,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-name">Profile Name</Label>
|
||||
<Input
|
||||
id="profile-name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="e.g., Heavy Task, Quick Fix"
|
||||
data-testid="profile-name-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-description">Description</Label>
|
||||
<Textarea
|
||||
id="profile-description"
|
||||
value={formData.description}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, description: e.target.value })
|
||||
}
|
||||
placeholder="Describe when to use this profile..."
|
||||
rows={2}
|
||||
data-testid="profile-description-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Icon Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label>Icon</Label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{ICON_OPTIONS.map(({ name, icon: Icon }) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, icon: name })}
|
||||
className={cn(
|
||||
"w-10 h-10 rounded-lg flex items-center justify-center border transition-colors",
|
||||
formData.icon === name
|
||||
? "bg-primary text-primary-foreground border-primary"
|
||||
: "bg-background hover:bg-accent border-input"
|
||||
)}
|
||||
data-testid={`icon-select-${name}`}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model Selection - Claude */}
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Brain className="w-4 h-4 text-primary" />
|
||||
Claude Models
|
||||
</Label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{CLAUDE_MODELS.map(({ id, label }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => handleModelChange(id)}
|
||||
className={cn(
|
||||
"flex-1 min-w-[100px] px-3 py-2 rounded-md border text-sm font-medium transition-colors",
|
||||
formData.model === id
|
||||
? "bg-primary text-primary-foreground border-primary"
|
||||
: "bg-background hover:bg-accent border-input"
|
||||
)}
|
||||
data-testid={`model-select-${id}`}
|
||||
>
|
||||
{label.replace("Claude ", "")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model Selection - Codex */}
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Zap className="w-4 h-4 text-emerald-500" />
|
||||
Codex Models
|
||||
</Label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{CODEX_MODELS.map(({ id, label }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => handleModelChange(id)}
|
||||
className={cn(
|
||||
"flex-1 min-w-[100px] px-3 py-2 rounded-md border text-sm font-medium transition-colors",
|
||||
formData.model === id
|
||||
? "bg-emerald-600 text-white border-emerald-500"
|
||||
: "bg-background hover:bg-accent border-input"
|
||||
)}
|
||||
data-testid={`model-select-${id}`}
|
||||
>
|
||||
{label.replace("GPT-5.1 ", "").replace("Codex ", "")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Thinking Level - Only for Claude models */}
|
||||
{supportsThinking && (
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Brain className="w-4 h-4 text-amber-500" />
|
||||
Thinking Level
|
||||
</Label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{THINKING_LEVELS.map(({ id, label }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFormData({ ...formData, thinkingLevel: id });
|
||||
if (id === "ultrathink") {
|
||||
toast.warning("Ultrathink uses extensive reasoning", {
|
||||
description:
|
||||
"Best for complex architecture, migrations, or deep debugging (~$0.48/task).",
|
||||
duration: 4000,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"flex-1 min-w-[70px] px-3 py-2 rounded-md border text-sm font-medium transition-colors",
|
||||
formData.thinkingLevel === id
|
||||
? "bg-amber-500 text-white border-amber-400"
|
||||
: "bg-background hover:bg-accent border-input"
|
||||
)}
|
||||
data-testid={`thinking-select-${id}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Higher levels give more time to reason through complex problems.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<DialogFooter className="pt-4">
|
||||
<Button variant="ghost" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<HotkeyButton
|
||||
onClick={handleSubmit}
|
||||
hotkey={{ key: "Enter", cmdCtrl: true }}
|
||||
hotkeyActive={hotkeyActive}
|
||||
data-testid="save-profile-button"
|
||||
>
|
||||
{isEditing ? "Save Changes" : "Create Profile"}
|
||||
</HotkeyButton>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProfilesView() {
|
||||
const {
|
||||
aiProfiles,
|
||||
addAIProfile,
|
||||
updateAIProfile,
|
||||
removeAIProfile,
|
||||
reorderAIProfiles,
|
||||
} = useAppStore();
|
||||
const shortcuts = useKeyboardShortcutsConfig();
|
||||
|
||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||
const [editingProfile, setEditingProfile] = useState<AIProfile | null>(null);
|
||||
|
||||
// Sensors for drag-and-drop
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 5,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Separate built-in and custom profiles
|
||||
const builtInProfiles = useMemo(
|
||||
() => aiProfiles.filter((p) => p.isBuiltIn),
|
||||
[aiProfiles]
|
||||
);
|
||||
const customProfiles = useMemo(
|
||||
() => aiProfiles.filter((p) => !p.isBuiltIn),
|
||||
[aiProfiles]
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = aiProfiles.findIndex((p) => p.id === active.id);
|
||||
const newIndex = aiProfiles.findIndex((p) => p.id === over.id);
|
||||
|
||||
if (oldIndex !== -1 && newIndex !== -1) {
|
||||
reorderAIProfiles(oldIndex, newIndex);
|
||||
}
|
||||
}
|
||||
},
|
||||
[aiProfiles, reorderAIProfiles]
|
||||
);
|
||||
|
||||
const handleAddProfile = (profile: Omit<AIProfile, "id">) => {
|
||||
addAIProfile(profile);
|
||||
setShowAddDialog(false);
|
||||
toast.success("Profile created", {
|
||||
description: `Created "${profile.name}" profile`,
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateProfile = (profile: Omit<AIProfile, "id">) => {
|
||||
if (editingProfile) {
|
||||
updateAIProfile(editingProfile.id, profile);
|
||||
setEditingProfile(null);
|
||||
toast.success("Profile updated", {
|
||||
description: `Updated "${profile.name}" profile`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteProfile = (profile: AIProfile) => {
|
||||
if (profile.isBuiltIn) return;
|
||||
|
||||
removeAIProfile(profile.id);
|
||||
toast.success("Profile deleted", {
|
||||
description: `Deleted "${profile.name}" profile`,
|
||||
});
|
||||
};
|
||||
|
||||
// Build keyboard shortcuts for profiles view
|
||||
const profilesShortcuts: KeyboardShortcut[] = useMemo(() => {
|
||||
const shortcutsList: KeyboardShortcut[] = [];
|
||||
|
||||
// Add profile shortcut - when in profiles view
|
||||
shortcutsList.push({
|
||||
key: shortcuts.addProfile,
|
||||
action: () => setShowAddDialog(true),
|
||||
description: "Create new profile",
|
||||
});
|
||||
|
||||
return shortcutsList;
|
||||
}, [shortcuts]);
|
||||
|
||||
// Register keyboard shortcuts for profiles view
|
||||
useKeyboardShortcuts(profilesShortcuts);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-1 flex flex-col overflow-hidden content-bg"
|
||||
data-testid="profiles-view"
|
||||
>
|
||||
{/* Header Section */}
|
||||
<div className="shrink-0 border-b border-border bg-glass backdrop-blur-md">
|
||||
<div className="px-8 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-linear-to-br from-brand-500 to-brand-600 shadow-lg shadow-brand-500/20 flex items-center justify-center">
|
||||
<UserCircle className="w-5 h-5 text-primary-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">
|
||||
AI Profiles
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create and manage model configuration presets
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<HotkeyButton
|
||||
onClick={() => setShowAddDialog(true)}
|
||||
hotkey={shortcuts.addProfile}
|
||||
hotkeyActive={false}
|
||||
data-testid="add-profile-button"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
New Profile
|
||||
</HotkeyButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-8">
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
{/* Custom Profiles Section */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
Custom Profiles
|
||||
</h2>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary">
|
||||
{customProfiles.length}
|
||||
</span>
|
||||
</div>
|
||||
{customProfiles.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-border p-8 text-center">
|
||||
<Sparkles className="w-10 h-10 text-muted-foreground mx-auto mb-3 opacity-50" />
|
||||
<p className="text-muted-foreground">
|
||||
No custom profiles yet. Create one to get started!
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
onClick={() => setShowAddDialog(true)}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create Profile
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={customProfiles.map((p) => p.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{customProfiles.map((profile) => (
|
||||
<SortableProfileCard
|
||||
key={profile.id}
|
||||
profile={profile}
|
||||
onEdit={() => setEditingProfile(profile)}
|
||||
onDelete={() => handleDeleteProfile(profile)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Built-in Profiles Section */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
Built-in Profiles
|
||||
</h2>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-muted text-muted-foreground">
|
||||
{builtInProfiles.length}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Pre-configured profiles for common use cases. These cannot be
|
||||
edited or deleted.
|
||||
</p>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={builtInProfiles.map((p) => p.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{builtInProfiles.map((profile) => (
|
||||
<SortableProfileCard
|
||||
key={profile.id}
|
||||
profile={profile}
|
||||
onEdit={() => {}}
|
||||
onDelete={() => {}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Profile Dialog */}
|
||||
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
|
||||
<DialogContent data-testid="add-profile-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Profile</DialogTitle>
|
||||
<DialogDescription>
|
||||
Define a reusable model configuration preset.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ProfileForm
|
||||
profile={{}}
|
||||
onSave={handleAddProfile}
|
||||
onCancel={() => setShowAddDialog(false)}
|
||||
isEditing={false}
|
||||
hotkeyActive={showAddDialog}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit Profile Dialog */}
|
||||
<Dialog
|
||||
open={!!editingProfile}
|
||||
onOpenChange={() => setEditingProfile(null)}
|
||||
>
|
||||
<DialogContent data-testid="edit-profile-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Profile</DialogTitle>
|
||||
<DialogDescription>Modify your profile settings.</DialogDescription>
|
||||
</DialogHeader>
|
||||
{editingProfile && (
|
||||
<ProfileForm
|
||||
profile={editingProfile}
|
||||
onSave={handleUpdateProfile}
|
||||
onCancel={() => setEditingProfile(null)}
|
||||
isEditing={true}
|
||||
hotkeyActive={!!editingProfile}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Key,
|
||||
Palette,
|
||||
Terminal,
|
||||
Atom,
|
||||
FlaskConical,
|
||||
Trash2,
|
||||
Settings2,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
} from "lucide-react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
|
||||
import { useCliStatus } from "./settings-view/hooks/use-cli-status";
|
||||
import { useScrollTracking } from "@/hooks/use-scroll-tracking";
|
||||
import { SettingsHeader } from "./settings-view/components/settings-header";
|
||||
import { KeyboardMapDialog } from "./settings-view/components/keyboard-map-dialog";
|
||||
import { DeleteProjectDialog } from "./settings-view/components/delete-project-dialog";
|
||||
import { SettingsNavigation } from "./settings-view/components/settings-navigation";
|
||||
import { ApiKeysSection } from "./settings-view/api-keys/api-keys-section";
|
||||
import { ClaudeCliStatus } from "./settings-view/cli-status/claude-cli-status";
|
||||
import { CodexCliStatus } from "./settings-view/cli-status/codex-cli-status";
|
||||
import { AppearanceSection } from "./settings-view/appearance/appearance-section";
|
||||
import { KeyboardShortcutsSection } from "./settings-view/keyboard-shortcuts/keyboard-shortcuts-section";
|
||||
import { FeatureDefaultsSection } from "./settings-view/feature-defaults/feature-defaults-section";
|
||||
import { DangerZoneSection } from "./settings-view/danger-zone/danger-zone-section";
|
||||
import type {
|
||||
Project as SettingsProject,
|
||||
Theme,
|
||||
} from "./settings-view/shared/types";
|
||||
import type { Project as ElectronProject } from "@/lib/electron";
|
||||
|
||||
// Navigation items for the side panel
|
||||
const NAV_ITEMS = [
|
||||
{ id: "api-keys", label: "API Keys", icon: Key },
|
||||
{ id: "claude", label: "Claude", icon: Terminal },
|
||||
{ id: "codex", label: "Codex", icon: Atom },
|
||||
{ id: "appearance", label: "Appearance", icon: Palette },
|
||||
{ id: "audio", label: "Audio", icon: Volume2 },
|
||||
{ id: "keyboard", label: "Keyboard Shortcuts", icon: Settings2 },
|
||||
{ id: "defaults", label: "Feature Defaults", icon: FlaskConical },
|
||||
{ id: "danger", label: "Danger Zone", icon: Trash2 },
|
||||
];
|
||||
|
||||
export function SettingsView() {
|
||||
const {
|
||||
theme,
|
||||
setTheme,
|
||||
setProjectTheme,
|
||||
defaultSkipTests,
|
||||
setDefaultSkipTests,
|
||||
useWorktrees,
|
||||
setUseWorktrees,
|
||||
showProfilesOnly,
|
||||
setShowProfilesOnly,
|
||||
muteDoneSound,
|
||||
setMuteDoneSound,
|
||||
currentProject,
|
||||
moveProjectToTrash,
|
||||
} = useAppStore();
|
||||
|
||||
// Convert electron Project to settings-view Project type
|
||||
const convertProject = (
|
||||
project: ElectronProject | null
|
||||
): SettingsProject | null => {
|
||||
if (!project) return null;
|
||||
return {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
path: project.path,
|
||||
theme: project.theme as Theme | undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const settingsProject = convertProject(currentProject);
|
||||
|
||||
// Compute the effective theme for the current project
|
||||
const effectiveTheme = (settingsProject?.theme || theme) as Theme;
|
||||
|
||||
// Handler to set theme - always updates global theme (user's preference),
|
||||
// and also sets per-project theme if a project is selected
|
||||
const handleSetTheme = (newTheme: typeof theme) => {
|
||||
// Always update global theme so user's preference persists across all projects
|
||||
setTheme(newTheme);
|
||||
// Also set per-project theme if a project is selected
|
||||
if (currentProject) {
|
||||
setProjectTheme(currentProject.id, newTheme);
|
||||
}
|
||||
};
|
||||
|
||||
// Use CLI status hook
|
||||
const {
|
||||
claudeCliStatus,
|
||||
codexCliStatus,
|
||||
isCheckingClaudeCli,
|
||||
isCheckingCodexCli,
|
||||
handleRefreshClaudeCli,
|
||||
handleRefreshCodexCli,
|
||||
} = useCliStatus();
|
||||
|
||||
// Use scroll tracking hook
|
||||
const { activeSection, scrollToSection, scrollContainerRef } =
|
||||
useScrollTracking({
|
||||
items: NAV_ITEMS,
|
||||
filterFn: (item) => item.id !== "danger" || !!currentProject,
|
||||
initialSection: "api-keys",
|
||||
});
|
||||
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [showKeyboardMapDialog, setShowKeyboardMapDialog] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-1 flex flex-col overflow-hidden content-bg"
|
||||
data-testid="settings-view"
|
||||
>
|
||||
{/* Header Section */}
|
||||
<SettingsHeader />
|
||||
|
||||
{/* Content Area with Sidebar */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Sticky Side Navigation */}
|
||||
<SettingsNavigation
|
||||
navItems={NAV_ITEMS}
|
||||
activeSection={activeSection}
|
||||
currentProject={currentProject}
|
||||
onNavigate={scrollToSection}
|
||||
/>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto p-8">
|
||||
<div className="max-w-4xl mx-auto space-y-6 pb-96">
|
||||
{/* API Keys Section */}
|
||||
<ApiKeysSection />
|
||||
|
||||
{/* Claude CLI Status Section */}
|
||||
{claudeCliStatus && (
|
||||
<ClaudeCliStatus
|
||||
status={claudeCliStatus}
|
||||
isChecking={isCheckingClaudeCli}
|
||||
onRefresh={handleRefreshClaudeCli}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Codex CLI Status Section */}
|
||||
{codexCliStatus && (
|
||||
<CodexCliStatus
|
||||
status={codexCliStatus}
|
||||
isChecking={isCheckingCodexCli}
|
||||
onRefresh={handleRefreshCodexCli}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Appearance Section */}
|
||||
<AppearanceSection
|
||||
effectiveTheme={effectiveTheme}
|
||||
currentProject={settingsProject}
|
||||
onThemeChange={handleSetTheme}
|
||||
/>
|
||||
|
||||
|
||||
{/* Keyboard Shortcuts Section */}
|
||||
<KeyboardShortcutsSection
|
||||
onOpenKeyboardMap={() => setShowKeyboardMapDialog(true)}
|
||||
/>
|
||||
|
||||
{/* Audio Section */}
|
||||
<div
|
||||
id="audio"
|
||||
className="rounded-xl border border-border bg-card backdrop-blur-md overflow-hidden scroll-mt-6"
|
||||
>
|
||||
<div className="p-6 border-b border-border">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Volume2 className="w-5 h-5 text-brand-500" />
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
Audio
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure audio and notification settings.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Mute Done Sound Setting */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="mute-done-sound"
|
||||
checked={muteDoneSound}
|
||||
onCheckedChange={(checked) =>
|
||||
setMuteDoneSound(checked === true)
|
||||
}
|
||||
className="mt-0.5"
|
||||
data-testid="mute-done-sound-checkbox"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label
|
||||
htmlFor="mute-done-sound"
|
||||
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
||||
>
|
||||
<VolumeX className="w-4 h-4 text-brand-500" />
|
||||
Mute notification sound when agents complete
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When enabled, disables the "ding" sound that
|
||||
plays when an agent completes a feature. The feature
|
||||
will still move to the completed column, but without
|
||||
audio notification.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feature Defaults Section */}
|
||||
<FeatureDefaultsSection
|
||||
showProfilesOnly={showProfilesOnly}
|
||||
defaultSkipTests={defaultSkipTests}
|
||||
useWorktrees={useWorktrees}
|
||||
onShowProfilesOnlyChange={setShowProfilesOnly}
|
||||
onDefaultSkipTestsChange={setDefaultSkipTests}
|
||||
onUseWorktreesChange={setUseWorktrees}
|
||||
/>
|
||||
|
||||
{/* Danger Zone Section - Only show when a project is selected */}
|
||||
<DangerZoneSection
|
||||
project={settingsProject}
|
||||
onDeleteClick={() => setShowDeleteDialog(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keyboard Map Dialog */}
|
||||
<KeyboardMapDialog
|
||||
open={showKeyboardMapDialog}
|
||||
onOpenChange={setShowKeyboardMapDialog}
|
||||
/>
|
||||
|
||||
{/* Delete Project Confirmation Dialog */}
|
||||
<DeleteProjectDialog
|
||||
open={showDeleteDialog}
|
||||
onOpenChange={setShowDeleteDialog}
|
||||
project={currentProject}
|
||||
onConfirm={moveProjectToTrash}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { useSetupStore } from "@/store/setup-store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Key, CheckCircle2 } from "lucide-react";
|
||||
import { ApiKeyField } from "./api-key-field";
|
||||
import { buildProviderConfigs } from "@/config/api-providers";
|
||||
import { AuthenticationStatusDisplay } from "./authentication-status-display";
|
||||
import { SecurityNotice } from "./security-notice";
|
||||
import { useApiKeyManagement } from "./hooks/use-api-key-management";
|
||||
|
||||
export function ApiKeysSection() {
|
||||
const { apiKeys } = useAppStore();
|
||||
const { claudeAuthStatus, codexAuthStatus } = useSetupStore();
|
||||
|
||||
const { providerConfigParams, apiKeyStatus, handleSave, saved } =
|
||||
useApiKeyManagement();
|
||||
|
||||
const providerConfigs = buildProviderConfigs(providerConfigParams);
|
||||
|
||||
return (
|
||||
<div
|
||||
id="api-keys"
|
||||
className="rounded-xl border border-border bg-card backdrop-blur-md overflow-hidden scroll-mt-6"
|
||||
>
|
||||
<div className="p-6 border-b border-border">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Key className="w-5 h-5 text-brand-500" />
|
||||
<h2 className="text-lg font-semibold text-foreground">API Keys</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure your AI provider API keys. Keys are stored locally in your
|
||||
browser.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-6">
|
||||
{/* API Key Fields */}
|
||||
{providerConfigs.map((provider) => (
|
||||
<ApiKeyField key={provider.key} config={provider} />
|
||||
))}
|
||||
|
||||
{/* Authentication Status Display */}
|
||||
<AuthenticationStatusDisplay
|
||||
claudeAuthStatus={claudeAuthStatus}
|
||||
codexAuthStatus={codexAuthStatus}
|
||||
apiKeyStatus={apiKeyStatus}
|
||||
apiKeys={apiKeys}
|
||||
/>
|
||||
|
||||
{/* Security Notice */}
|
||||
<SecurityNotice />
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex items-center gap-4 pt-2">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
data-testid="save-settings"
|
||||
className="min-w-[120px] bg-linear-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-600 text-primary-foreground border-0"
|
||||
>
|
||||
{saved ? (
|
||||
<>
|
||||
<CheckCircle2 className="w-4 h-4 mr-2" />
|
||||
Saved!
|
||||
</>
|
||||
) : (
|
||||
"Save API Keys"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Info,
|
||||
Terminal,
|
||||
Atom,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import type { ClaudeAuthStatus, CodexAuthStatus } from "@/store/setup-store";
|
||||
|
||||
interface AuthenticationStatusDisplayProps {
|
||||
claudeAuthStatus: ClaudeAuthStatus | null;
|
||||
codexAuthStatus: CodexAuthStatus | null;
|
||||
apiKeyStatus: {
|
||||
hasAnthropicKey: boolean;
|
||||
hasOpenAIKey: boolean;
|
||||
hasGoogleKey: boolean;
|
||||
} | null;
|
||||
apiKeys: {
|
||||
anthropic: string;
|
||||
google: string;
|
||||
openai: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function AuthenticationStatusDisplay({
|
||||
claudeAuthStatus,
|
||||
codexAuthStatus,
|
||||
apiKeyStatus,
|
||||
apiKeys,
|
||||
}: AuthenticationStatusDisplayProps) {
|
||||
return (
|
||||
<div className="space-y-4 pt-4 border-t border-border">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Info className="w-4 h-4 text-brand-500" />
|
||||
<Label className="text-foreground font-semibold">
|
||||
Current Authentication Configuration
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Claude Authentication Status */}
|
||||
<div className="p-3 rounded-lg bg-card border border-border">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<Terminal className="w-4 h-4 text-brand-500" />
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
Claude (Anthropic)
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1.5 text-xs min-h-12">
|
||||
{claudeAuthStatus?.authenticated ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-3 h-3 text-green-500 shrink-0" />
|
||||
<span className="text-green-400 font-medium">Authenticated</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Info className="w-3 h-3 shrink-0" />
|
||||
<span>
|
||||
{claudeAuthStatus.method === "oauth_token_env"
|
||||
? "Using CLAUDE_CODE_OAUTH_TOKEN"
|
||||
: claudeAuthStatus.method === "oauth_token"
|
||||
? "Using stored OAuth token (subscription)"
|
||||
: claudeAuthStatus.method === "api_key_env"
|
||||
? "Using ANTHROPIC_API_KEY"
|
||||
: claudeAuthStatus.method === "api_key"
|
||||
? "Using stored API key"
|
||||
: claudeAuthStatus.method === "credentials_file"
|
||||
? "Using credentials file"
|
||||
: claudeAuthStatus.method === "cli_authenticated"
|
||||
? "Using Claude CLI authentication"
|
||||
: `Using ${claudeAuthStatus.method || "detected"} authentication`}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : apiKeyStatus?.hasAnthropicKey ? (
|
||||
<div className="flex items-center gap-2 text-blue-400">
|
||||
<Info className="w-3 h-3 shrink-0" />
|
||||
<span>Using environment variable (ANTHROPIC_API_KEY)</span>
|
||||
</div>
|
||||
) : apiKeys.anthropic ? (
|
||||
<div className="flex items-center gap-2 text-blue-400">
|
||||
<Info className="w-3 h-3 shrink-0" />
|
||||
<span>Using manual API key from settings</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 text-yellow-500 py-0.5">
|
||||
<AlertCircle className="w-3 h-3 shrink-0" />
|
||||
<span className="text-xs">Not configured</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Codex/OpenAI Authentication Status */}
|
||||
<div className="p-3 rounded-lg bg-card border border-border">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<Atom className="w-4 h-4 text-green-500" />
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
Codex (OpenAI)
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1.5 text-xs min-h-12">
|
||||
{codexAuthStatus?.authenticated ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-3 h-3 text-green-500 shrink-0" />
|
||||
<span className="text-green-400 font-medium">Authenticated</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Info className="w-3 h-3 shrink-0" />
|
||||
<span>
|
||||
{codexAuthStatus.method === "subscription"
|
||||
? "Using Codex subscription (Plus/Team)"
|
||||
: codexAuthStatus.method === "cli_verified" ||
|
||||
codexAuthStatus.method === "cli_tokens"
|
||||
? "Using CLI login (OpenAI account)"
|
||||
: codexAuthStatus.method === "api_key"
|
||||
? "Using stored API key"
|
||||
: codexAuthStatus.method === "env"
|
||||
? "Using OPENAI_API_KEY"
|
||||
: `Using ${codexAuthStatus.method || "unknown"} authentication`}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : apiKeyStatus?.hasOpenAIKey ? (
|
||||
<div className="flex items-center gap-2 text-blue-400">
|
||||
<Info className="w-3 h-3 shrink-0" />
|
||||
<span>Using environment variable (OPENAI_API_KEY)</span>
|
||||
</div>
|
||||
) : apiKeys.openai ? (
|
||||
<div className="flex items-center gap-2 text-blue-400">
|
||||
<Info className="w-3 h-3 shrink-0" />
|
||||
<span>Using manual API key from settings</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 text-yellow-500 py-0.5">
|
||||
<AlertCircle className="w-3 h-3 shrink-0" />
|
||||
<span className="text-xs">Not configured</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Google/Gemini Authentication Status */}
|
||||
<div className="p-3 rounded-lg bg-card border border-border">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<Sparkles className="w-4 h-4 text-purple-500" />
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
Gemini (Google)
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1.5 text-xs min-h-12">
|
||||
{apiKeyStatus?.hasGoogleKey ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-3 h-3 text-green-500 shrink-0" />
|
||||
<span className="text-green-400 font-medium">Authenticated</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Info className="w-3 h-3 shrink-0" />
|
||||
<span>Using GOOGLE_API_KEY</span>
|
||||
</div>
|
||||
</>
|
||||
) : apiKeys.google ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-3 h-3 text-green-500 shrink-0" />
|
||||
<span className="text-green-400 font-medium">Authenticated</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Info className="w-3 h-3 shrink-0" />
|
||||
<span>Using stored API key</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 text-yellow-500 py-0.5">
|
||||
<AlertCircle className="w-3 h-3 shrink-0" />
|
||||
<span className="text-xs">Not configured</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Palette } from "lucide-react";
|
||||
import { themeOptions } from "@/config/theme-options";
|
||||
import type { Theme, Project } from "../shared/types";
|
||||
|
||||
interface AppearanceSectionProps {
|
||||
effectiveTheme: Theme;
|
||||
currentProject: Project | null;
|
||||
onThemeChange: (theme: Theme) => void;
|
||||
}
|
||||
|
||||
export function AppearanceSection({
|
||||
effectiveTheme,
|
||||
currentProject,
|
||||
onThemeChange,
|
||||
}: AppearanceSectionProps) {
|
||||
return (
|
||||
<div
|
||||
id="appearance"
|
||||
className="rounded-xl border border-border bg-card backdrop-blur-md overflow-hidden scroll-mt-6"
|
||||
>
|
||||
<div className="p-6 border-b border-border">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Palette className="w-5 h-5 text-brand-500" />
|
||||
<h2 className="text-lg font-semibold text-foreground">Appearance</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Customize the look and feel of your application.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="space-y-3">
|
||||
<Label className="text-foreground">
|
||||
Theme{" "}
|
||||
{currentProject ? `(for ${currentProject.name})` : "(Global)"}
|
||||
</Label>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{themeOptions.map(({ value, label, Icon, testId }) => {
|
||||
const isActive = effectiveTheme === value;
|
||||
return (
|
||||
<Button
|
||||
key={value}
|
||||
variant={isActive ? "secondary" : "outline"}
|
||||
onClick={() => onThemeChange(value)}
|
||||
className={`flex items-center justify-center gap-2 px-3 py-3 h-auto ${
|
||||
isActive ? "border-brand-500 ring-1 ring-brand-500/50" : ""
|
||||
}`}
|
||||
data-testid={testId}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span className="font-medium text-sm">{label}</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Terminal,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import type { CliStatus } from "../shared/types";
|
||||
|
||||
interface CliStatusProps {
|
||||
status: CliStatus | null;
|
||||
isChecking: boolean;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export function ClaudeCliStatus({
|
||||
status,
|
||||
isChecking,
|
||||
onRefresh,
|
||||
}: CliStatusProps) {
|
||||
if (!status) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
id="claude"
|
||||
className="rounded-xl border border-border bg-card backdrop-blur-md overflow-hidden scroll-mt-6"
|
||||
>
|
||||
<div className="p-6 border-b border-border">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="w-5 h-5 text-brand-500" />
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
Claude Code CLI
|
||||
</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onRefresh}
|
||||
disabled={isChecking}
|
||||
data-testid="refresh-claude-cli"
|
||||
title="Refresh Claude CLI detection"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`w-4 h-4 ${isChecking ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Claude Code CLI provides better performance for long-running tasks,
|
||||
especially with ultrathink.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{status.success && status.status === "installed" ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-green-500/10 border border-green-500/20">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-green-400">
|
||||
Claude Code CLI Installed
|
||||
</p>
|
||||
<div className="text-xs text-green-400/80 mt-1 space-y-1">
|
||||
{status.method && (
|
||||
<p>
|
||||
Method: <span className="font-mono">{status.method}</span>
|
||||
</p>
|
||||
)}
|
||||
{status.version && (
|
||||
<p>
|
||||
Version:{" "}
|
||||
<span className="font-mono">{status.version}</span>
|
||||
</p>
|
||||
)}
|
||||
{status.path && (
|
||||
<p className="truncate" title={status.path}>
|
||||
Path:{" "}
|
||||
<span className="font-mono text-[10px]">
|
||||
{status.path}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{status.recommendation && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{status.recommendation}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
|
||||
<AlertCircle className="w-5 h-5 text-yellow-500 mt-0.5 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-yellow-400">
|
||||
Claude Code CLI Not Detected
|
||||
</p>
|
||||
<p className="text-xs text-yellow-400/80 mt-1">
|
||||
{status.recommendation ||
|
||||
"Consider installing Claude Code CLI for optimal performance with ultrathink."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{status.installCommands && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-foreground-secondary">
|
||||
Installation Commands:
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{status.installCommands.npm && (
|
||||
<div className="p-2 rounded bg-background border border-border-glass">
|
||||
<p className="text-xs text-muted-foreground mb-1">npm:</p>
|
||||
<code className="text-xs text-foreground-secondary font-mono break-all">
|
||||
{status.installCommands.npm}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
{status.installCommands.macos && (
|
||||
<div className="p-2 rounded bg-background border border-border-glass">
|
||||
<p className="text-xs text-muted-foreground mb-1">
|
||||
macOS/Linux:
|
||||
</p>
|
||||
<code className="text-xs text-foreground-secondary font-mono break-all">
|
||||
{status.installCommands.macos}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
{status.installCommands.windows && (
|
||||
<div className="p-2 rounded bg-background border border-border-glass">
|
||||
<p className="text-xs text-muted-foreground mb-1">
|
||||
Windows (PowerShell):
|
||||
</p>
|
||||
<code className="text-xs text-foreground-secondary font-mono break-all">
|
||||
{status.installCommands.windows}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Terminal,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import type { CliStatus } from "../shared/types";
|
||||
|
||||
interface CliStatusProps {
|
||||
status: CliStatus | null;
|
||||
isChecking: boolean;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export function CodexCliStatus({
|
||||
status,
|
||||
isChecking,
|
||||
onRefresh,
|
||||
}: CliStatusProps) {
|
||||
if (!status) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
id="codex"
|
||||
className="rounded-xl border border-border bg-card backdrop-blur-md overflow-hidden scroll-mt-6"
|
||||
>
|
||||
<div className="p-6 border-b border-border">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="w-5 h-5 text-green-500" />
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
OpenAI Codex CLI
|
||||
</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onRefresh}
|
||||
disabled={isChecking}
|
||||
data-testid="refresh-codex-cli"
|
||||
title="Refresh Codex CLI detection"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`w-4 h-4 ${isChecking ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Codex CLI enables GPT-5.1 Codex models for autonomous coding tasks.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{status.success && status.status === "installed" ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-green-500/10 border border-green-500/20">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-green-400">
|
||||
Codex CLI Installed
|
||||
</p>
|
||||
<div className="text-xs text-green-400/80 mt-1 space-y-1">
|
||||
{status.method && (
|
||||
<p>
|
||||
Method: <span className="font-mono">{status.method}</span>
|
||||
</p>
|
||||
)}
|
||||
{status.version && (
|
||||
<p>
|
||||
Version:{" "}
|
||||
<span className="font-mono">{status.version}</span>
|
||||
</p>
|
||||
)}
|
||||
{status.path && (
|
||||
<p className="truncate" title={status.path}>
|
||||
Path:{" "}
|
||||
<span className="font-mono text-[10px]">
|
||||
{status.path}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{status.recommendation && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{status.recommendation}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : status.status === "api_key_only" ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-blue-500/10 border border-blue-500/20">
|
||||
<AlertCircle className="w-5 h-5 text-blue-500 mt-0.5 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-blue-400">
|
||||
API Key Detected - CLI Not Installed
|
||||
</p>
|
||||
<p className="text-xs text-blue-400/80 mt-1">
|
||||
{status.recommendation ||
|
||||
"OPENAI_API_KEY found but Codex CLI not installed. Install the CLI for full agentic capabilities."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{status.installCommands && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-foreground-secondary">
|
||||
Installation Commands:
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{status.installCommands.npm && (
|
||||
<div className="p-2 rounded bg-background border border-border-glass">
|
||||
<p className="text-xs text-muted-foreground mb-1">npm:</p>
|
||||
<code className="text-xs text-foreground-secondary font-mono break-all">
|
||||
{status.installCommands.npm}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
|
||||
<AlertCircle className="w-5 h-5 text-yellow-500 mt-0.5 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-yellow-400">
|
||||
Codex CLI Not Detected
|
||||
</p>
|
||||
<p className="text-xs text-yellow-400/80 mt-1">
|
||||
{status.recommendation ||
|
||||
"Install OpenAI Codex CLI to use GPT-5.1 Codex models for autonomous coding."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{status.installCommands && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-foreground-secondary">
|
||||
Installation Commands:
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{status.installCommands.npm && (
|
||||
<div className="p-2 rounded bg-background border border-border-glass">
|
||||
<p className="text-xs text-muted-foreground mb-1">npm:</p>
|
||||
<code className="text-xs text-foreground-secondary font-mono break-all">
|
||||
{status.installCommands.npm}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
{status.installCommands.macos && (
|
||||
<div className="p-2 rounded bg-background border border-border-glass">
|
||||
<p className="text-xs text-muted-foreground mb-1">
|
||||
macOS (Homebrew):
|
||||
</p>
|
||||
<code className="text-xs text-foreground-secondary font-mono break-all">
|
||||
{status.installCommands.macos}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { Settings } from "lucide-react";
|
||||
|
||||
interface SettingsHeaderProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function SettingsHeader({
|
||||
title = "Settings",
|
||||
description = "Configure your API keys and preferences",
|
||||
}: SettingsHeaderProps) {
|
||||
return (
|
||||
<div className="shrink-0 border-b border-border bg-glass backdrop-blur-md">
|
||||
<div className="px-8 py-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-linear-to-br from-brand-500 to-brand-600 shadow-lg shadow-brand-500/20 flex items-center justify-center">
|
||||
<Settings className="w-5 h-5 text-primary-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">{title}</h1>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Project } from "@/lib/electron";
|
||||
import type { NavigationItem } from "../config/navigation";
|
||||
|
||||
interface SettingsNavigationProps {
|
||||
navItems: NavigationItem[];
|
||||
activeSection: string;
|
||||
currentProject: Project | null;
|
||||
onNavigate: (sectionId: string) => void;
|
||||
}
|
||||
|
||||
export function SettingsNavigation({
|
||||
navItems,
|
||||
activeSection,
|
||||
currentProject,
|
||||
onNavigate,
|
||||
}: SettingsNavigationProps) {
|
||||
return (
|
||||
<nav className="hidden lg:block w-48 shrink-0 border-r border-border bg-card/50 backdrop-blur-sm">
|
||||
<div className="sticky top-0 p-4 space-y-1">
|
||||
{navItems
|
||||
.filter((item) => item.id !== "danger" || currentProject)
|
||||
.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = activeSection === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onNavigate(item.id)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-all text-left",
|
||||
isActive
|
||||
? "bg-brand-500/10 text-brand-500 border border-brand-500/20"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
"w-4 h-4 shrink-0",
|
||||
isActive ? "text-brand-500" : ""
|
||||
)}
|
||||
/>
|
||||
<span className="truncate">{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Trash2, Folder } from "lucide-react";
|
||||
import type { Project } from "../shared/types";
|
||||
|
||||
interface DangerZoneSectionProps {
|
||||
project: Project | null;
|
||||
onDeleteClick: () => void;
|
||||
}
|
||||
|
||||
export function DangerZoneSection({
|
||||
project,
|
||||
onDeleteClick,
|
||||
}: DangerZoneSectionProps) {
|
||||
if (!project) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
id="danger"
|
||||
className="rounded-xl border border-destructive/30 bg-card backdrop-blur-md overflow-hidden scroll-mt-6"
|
||||
>
|
||||
<div className="p-6 border-b border-destructive/30">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Trash2 className="w-5 h-5 text-destructive" />
|
||||
<h2 className="text-lg font-semibold text-foreground">Danger Zone</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Permanently remove this project from Automaker.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="w-10 h-10 rounded-lg bg-sidebar-accent/20 border border-sidebar-border flex items-center justify-center shrink-0">
|
||||
<Folder className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-foreground truncate">
|
||||
{project.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{project.path}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={onDeleteClick}
|
||||
data-testid="delete-project-button"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete Project
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { FlaskConical, Settings2, TestTube, GitBranch } from "lucide-react";
|
||||
|
||||
interface FeatureDefaultsSectionProps {
|
||||
showProfilesOnly: boolean;
|
||||
defaultSkipTests: boolean;
|
||||
useWorktrees: boolean;
|
||||
onShowProfilesOnlyChange: (value: boolean) => void;
|
||||
onDefaultSkipTestsChange: (value: boolean) => void;
|
||||
onUseWorktreesChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export function FeatureDefaultsSection({
|
||||
showProfilesOnly,
|
||||
defaultSkipTests,
|
||||
useWorktrees,
|
||||
onShowProfilesOnlyChange,
|
||||
onDefaultSkipTestsChange,
|
||||
onUseWorktreesChange,
|
||||
}: FeatureDefaultsSectionProps) {
|
||||
return (
|
||||
<div
|
||||
id="defaults"
|
||||
className="rounded-xl border border-border bg-card backdrop-blur-md overflow-hidden scroll-mt-6"
|
||||
>
|
||||
<div className="p-6 border-b border-border">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FlaskConical className="w-5 h-5 text-brand-500" />
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
Feature Defaults
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure default settings for new features.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Profiles Only Setting */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="show-profiles-only"
|
||||
checked={showProfilesOnly}
|
||||
onCheckedChange={(checked) =>
|
||||
onShowProfilesOnlyChange(checked === true)
|
||||
}
|
||||
className="mt-0.5"
|
||||
data-testid="show-profiles-only-checkbox"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label
|
||||
htmlFor="show-profiles-only"
|
||||
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
||||
>
|
||||
<Settings2 className="w-4 h-4 text-brand-500" />
|
||||
Show profiles only by default
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When enabled, the Add Feature dialog will show only AI profiles
|
||||
and hide advanced model tweaking options (Claude SDK, thinking
|
||||
levels, and OpenAI Codex CLI). This creates a cleaner, less
|
||||
overwhelming UI. You can always disable this to access advanced
|
||||
settings.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Automated Testing Setting */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="default-skip-tests"
|
||||
checked={!defaultSkipTests}
|
||||
onCheckedChange={(checked) =>
|
||||
onDefaultSkipTestsChange(checked !== true)
|
||||
}
|
||||
className="mt-0.5"
|
||||
data-testid="default-skip-tests-checkbox"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label
|
||||
htmlFor="default-skip-tests"
|
||||
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
||||
>
|
||||
<TestTube className="w-4 h-4 text-brand-500" />
|
||||
Enable automated testing by default
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When enabled, new features will use TDD (test-driven
|
||||
development) with automated tests. When disabled, features will
|
||||
require manual verification. You can still override this for
|
||||
individual features.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Worktree Isolation Setting */}
|
||||
<div className="space-y-3 pt-2 border-t border-border">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="use-worktrees"
|
||||
checked={useWorktrees}
|
||||
onCheckedChange={(checked) =>
|
||||
onUseWorktreesChange(checked === true)
|
||||
}
|
||||
className="mt-0.5"
|
||||
data-testid="use-worktrees-checkbox"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label
|
||||
htmlFor="use-worktrees"
|
||||
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
||||
>
|
||||
<GitBranch className="w-4 h-4 text-brand-500" />
|
||||
Enable Git Worktree Isolation (experimental)
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Creates isolated git branches for each feature. When disabled,
|
||||
agents work directly in the main project directory. This feature
|
||||
is experimental and may require additional setup like branch
|
||||
selection and merge configuration.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Settings2, Keyboard } from "lucide-react";
|
||||
|
||||
interface KeyboardShortcutsSectionProps {
|
||||
onOpenKeyboardMap: () => void;
|
||||
}
|
||||
|
||||
export function KeyboardShortcutsSection({
|
||||
onOpenKeyboardMap,
|
||||
}: KeyboardShortcutsSectionProps) {
|
||||
return (
|
||||
<div
|
||||
id="keyboard"
|
||||
className="rounded-xl border border-border bg-card backdrop-blur-md overflow-hidden scroll-mt-6"
|
||||
>
|
||||
<div className="p-6 border-b border-border">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Settings2 className="w-5 h-5 text-brand-500" />
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
Keyboard Shortcuts
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Customize keyboard shortcuts for navigation and actions using the
|
||||
visual keyboard map.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
{/* Centered message directing to keyboard map */}
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center space-y-4">
|
||||
<div className="relative">
|
||||
<Keyboard className="w-16 h-16 text-brand-500/30" />
|
||||
<div className="absolute inset-0 bg-brand-500/10 blur-xl rounded-full" />
|
||||
</div>
|
||||
<div className="space-y-2 max-w-md">
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
Use the Visual Keyboard Map
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Click the "View Keyboard Map" button above to customize
|
||||
your keyboard shortcuts. The visual interface shows all available
|
||||
keys and lets you easily edit shortcuts with single-modifier
|
||||
restrictions.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
size="lg"
|
||||
onClick={onOpenKeyboardMap}
|
||||
className="gap-2 mt-4"
|
||||
>
|
||||
<Keyboard className="w-5 h-5" />
|
||||
Open Keyboard Map
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
// Re-export all setup dialog components for easier imports
|
||||
export { SetupTokenModal } from "./setup-token-modal";
|
||||
@@ -1,262 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Loader2,
|
||||
Terminal,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Copy,
|
||||
RotateCcw,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useOAuthAuthentication } from "../hooks";
|
||||
|
||||
interface SetupTokenModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onTokenObtained: (token: string) => void;
|
||||
}
|
||||
|
||||
export function SetupTokenModal({
|
||||
open,
|
||||
onClose,
|
||||
onTokenObtained,
|
||||
}: SetupTokenModalProps) {
|
||||
// Use the OAuth authentication hook
|
||||
const { authState, output, token, error, startAuth, reset } =
|
||||
useOAuthAuthentication({ cliType: "claude" });
|
||||
|
||||
const [manualToken, setManualToken] = useState("");
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-scroll to bottom when output changes
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [output]);
|
||||
|
||||
// Reset state when modal opens/closes
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
reset();
|
||||
setManualToken("");
|
||||
}
|
||||
}, [open, reset]);
|
||||
|
||||
const handleUseToken = useCallback(() => {
|
||||
const tokenToUse = token || manualToken;
|
||||
if (tokenToUse.trim()) {
|
||||
onTokenObtained(tokenToUse.trim());
|
||||
onClose();
|
||||
}
|
||||
}, [token, manualToken, onTokenObtained, onClose]);
|
||||
|
||||
const copyCommand = useCallback(() => {
|
||||
navigator.clipboard.writeText("claude setup-token");
|
||||
toast.success("Command copied to clipboard");
|
||||
}, []);
|
||||
|
||||
const handleRetry = useCallback(() => {
|
||||
reset();
|
||||
setManualToken("");
|
||||
}, [reset]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent
|
||||
className="max-w-2xl bg-card border-border"
|
||||
data-testid="setup-token-modal"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-foreground">
|
||||
<Terminal className="w-5 h-5 text-brand-500" />
|
||||
Claude Subscription Authentication
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
{authState === "idle" &&
|
||||
"Click Start to begin the authentication process."}
|
||||
{authState === "running" &&
|
||||
"Complete the sign-in in your browser..."}
|
||||
{authState === "success" &&
|
||||
"Authentication successful! Your token has been captured."}
|
||||
{authState === "error" &&
|
||||
"Authentication failed. Please try again or enter the token manually."}
|
||||
{authState === "manual" &&
|
||||
"Copy the token from your terminal and paste it below."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Terminal Output */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="bg-zinc-900 rounded-lg p-4 font-mono text-sm max-h-48 overflow-y-auto border border-border mt-3"
|
||||
>
|
||||
{output.map((line, index) => (
|
||||
<div key={index} className="text-zinc-300 whitespace-pre-wrap">
|
||||
{line.startsWith("Error") || line.startsWith("⚠") ? (
|
||||
<span className="text-yellow-400">{line}</span>
|
||||
) : line.startsWith("✓") ? (
|
||||
<span className="text-green-400">{line}</span>
|
||||
) : (
|
||||
line
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{output.length === 0 && (
|
||||
<div className="text-zinc-500 italic">Waiting to start...</div>
|
||||
)}
|
||||
{authState === "running" && (
|
||||
<div className="flex items-center gap-2 text-brand-400 mt-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span>Waiting for authentication...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Manual Token Input (for fallback) */}
|
||||
{(authState === "manual" || authState === "error") && (
|
||||
<div className="space-y-3 pt-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>Run this command in your terminal:</span>
|
||||
<code className="bg-muted px-2 py-1 rounded font-mono text-foreground">
|
||||
claude setup-token
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={copyCommand}
|
||||
className="h-7 w-7"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="manual-token" className="text-foreground">
|
||||
Paste your token:
|
||||
</Label>
|
||||
<Input
|
||||
id="manual-token"
|
||||
type="password"
|
||||
placeholder="Paste token here..."
|
||||
value={manualToken}
|
||||
onChange={(e) => setManualToken(e.target.value)}
|
||||
className="bg-input border-border text-foreground"
|
||||
data-testid="manual-token-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success State */}
|
||||
{authState === "success" && (
|
||||
<div className="flex items-center gap-3 p-4 rounded-lg bg-green-500/10 border border-green-500/20">
|
||||
<CheckCircle2 className="w-6 h-6 text-green-500 shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
Token captured successfully!
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Click "Use Token" to save and continue.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && authState === "error" && (
|
||||
<div className="flex items-center gap-3 p-4 rounded-lg bg-red-500/10 border border-red-500/20">
|
||||
<XCircle className="w-6 h-6 text-red-500 shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium text-foreground">Error</p>
|
||||
<p className="text-sm text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="mt-5 flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
{authState === "idle" && (
|
||||
<Button
|
||||
onClick={startAuth}
|
||||
className="bg-brand-500 hover:bg-brand-600 text-white"
|
||||
data-testid="start-auth-button"
|
||||
>
|
||||
<Terminal className="w-4 h-4 mr-2" />
|
||||
Start Authentication
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{authState === "running" && (
|
||||
<Button disabled className="bg-brand-500">
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Authenticating...
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{authState === "success" && (
|
||||
<Button
|
||||
onClick={handleUseToken}
|
||||
className="bg-green-500 hover:bg-green-600 text-white"
|
||||
data-testid="use-token-button"
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4 mr-2" />
|
||||
Use Token
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{authState === "manual" && (
|
||||
<Button
|
||||
onClick={handleUseToken}
|
||||
disabled={!manualToken.trim()}
|
||||
className="bg-brand-500 hover:bg-brand-600 text-white disabled:opacity-50"
|
||||
data-testid="use-manual-token-button"
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4 mr-2" />
|
||||
Use Token
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{authState === "error" && (
|
||||
<>
|
||||
{manualToken.trim() && (
|
||||
<Button
|
||||
onClick={handleUseToken}
|
||||
className="bg-green-500 hover:bg-green-600 text-white"
|
||||
>
|
||||
Use Manual Token
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleRetry}
|
||||
className="bg-brand-500 hover:bg-brand-600 text-white"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import { useState, useCallback } from "react";
|
||||
|
||||
interface UseCliStatusOptions {
|
||||
cliType: "claude" | "codex";
|
||||
statusApi: () => Promise<any>;
|
||||
setCliStatus: (status: any) => void;
|
||||
setAuthStatus: (status: any) => void;
|
||||
}
|
||||
|
||||
export function useCliStatus({
|
||||
cliType,
|
||||
statusApi,
|
||||
setCliStatus,
|
||||
setAuthStatus,
|
||||
}: UseCliStatusOptions) {
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
|
||||
const checkStatus = useCallback(async () => {
|
||||
console.log(`[${cliType} Setup] Starting status check...`);
|
||||
setIsChecking(true);
|
||||
try {
|
||||
const result = await statusApi();
|
||||
console.log(`[${cliType} Setup] Raw status result:`, result);
|
||||
|
||||
if (result.success) {
|
||||
const cliStatus = {
|
||||
installed: result.status === "installed",
|
||||
path: result.path || null,
|
||||
version: result.version || null,
|
||||
method: result.method || "none",
|
||||
};
|
||||
console.log(`[${cliType} Setup] CLI Status:`, cliStatus);
|
||||
setCliStatus(cliStatus);
|
||||
|
||||
if (result.auth) {
|
||||
if (cliType === "claude") {
|
||||
// Validate method is one of the expected values, default to "none"
|
||||
const validMethods = [
|
||||
"oauth_token_env",
|
||||
"oauth_token",
|
||||
"api_key",
|
||||
"api_key_env",
|
||||
"credentials_file",
|
||||
"cli_authenticated",
|
||||
"none",
|
||||
] as const;
|
||||
type AuthMethod = (typeof validMethods)[number];
|
||||
const method: AuthMethod = validMethods.includes(
|
||||
result.auth.method as AuthMethod
|
||||
)
|
||||
? (result.auth.method as AuthMethod)
|
||||
: "none";
|
||||
const authStatus = {
|
||||
authenticated: result.auth.authenticated,
|
||||
method,
|
||||
hasCredentialsFile: false,
|
||||
oauthTokenValid:
|
||||
result.auth.hasStoredOAuthToken ||
|
||||
result.auth.hasEnvOAuthToken,
|
||||
apiKeyValid:
|
||||
result.auth.hasStoredApiKey || result.auth.hasEnvApiKey,
|
||||
hasEnvOAuthToken: result.auth.hasEnvOAuthToken,
|
||||
hasEnvApiKey: result.auth.hasEnvApiKey,
|
||||
};
|
||||
setAuthStatus(authStatus);
|
||||
} else {
|
||||
// Codex auth status mapping
|
||||
const mapAuthMethod = (method?: string): any => {
|
||||
switch (method) {
|
||||
case "cli_verified":
|
||||
return "cli_verified";
|
||||
case "cli_tokens":
|
||||
return "cli_tokens";
|
||||
case "auth_file":
|
||||
return "api_key";
|
||||
case "env_var":
|
||||
return "env";
|
||||
default:
|
||||
return "none";
|
||||
}
|
||||
};
|
||||
|
||||
const method = mapAuthMethod(result.auth.method);
|
||||
const authStatus = {
|
||||
authenticated: result.auth.authenticated,
|
||||
method,
|
||||
apiKeyValid:
|
||||
method === "cli_verified" || method === "cli_tokens"
|
||||
? undefined
|
||||
: result.auth.authenticated,
|
||||
};
|
||||
console.log(`[${cliType} Setup] Auth Status:`, authStatus);
|
||||
setAuthStatus(authStatus);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[${cliType} Setup] Failed to check status:`, error);
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
}, [cliType, statusApi, setCliStatus, setAuthStatus]);
|
||||
|
||||
return { isChecking, checkStatus };
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
|
||||
type AuthState = "idle" | "running" | "success" | "error" | "manual";
|
||||
|
||||
interface UseOAuthAuthenticationOptions {
|
||||
cliType: "claude" | "codex";
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function useOAuthAuthentication({
|
||||
cliType,
|
||||
enabled = true,
|
||||
}: UseOAuthAuthenticationOptions) {
|
||||
const [authState, setAuthState] = useState<AuthState>("idle");
|
||||
const [output, setOutput] = useState<string[]>([]);
|
||||
const [token, setToken] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const unsubscribeRef = useRef<(() => void) | null>(null);
|
||||
|
||||
// Reset state when disabled
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setAuthState("idle");
|
||||
setOutput([]);
|
||||
setToken("");
|
||||
setError(null);
|
||||
|
||||
// Cleanup subscription
|
||||
if (unsubscribeRef.current) {
|
||||
unsubscribeRef.current();
|
||||
unsubscribeRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [enabled]);
|
||||
|
||||
const startAuth = useCallback(async () => {
|
||||
const api = getElectronAPI();
|
||||
if (!api.setup) {
|
||||
setError("Setup API not available");
|
||||
setAuthState("error");
|
||||
return;
|
||||
}
|
||||
|
||||
setAuthState("running");
|
||||
setOutput([
|
||||
"Starting authentication...",
|
||||
`Running ${cliType} CLI in an embedded terminal so you don't need to copy/paste.`,
|
||||
"When your browser opens, complete sign-in and return here.",
|
||||
"",
|
||||
]);
|
||||
setError(null);
|
||||
setToken("");
|
||||
|
||||
// Subscribe to progress events
|
||||
if (api.setup.onAuthProgress) {
|
||||
unsubscribeRef.current = api.setup.onAuthProgress((progress) => {
|
||||
if (progress.cli === cliType && progress.data) {
|
||||
// Split by newlines and add each line
|
||||
const normalized = progress.data.replace(/\r/g, "\n");
|
||||
const lines = normalized
|
||||
.split("\n")
|
||||
.map((line: string) => line.trimEnd())
|
||||
.filter((line: string) => line.length > 0);
|
||||
if (lines.length > 0) {
|
||||
setOutput((prev) => [...prev, ...lines]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Call the appropriate auth API based on cliType
|
||||
const result =
|
||||
cliType === "claude"
|
||||
? await api.setup.authClaude()
|
||||
: await api.setup.authCodex?.();
|
||||
|
||||
// Cleanup subscription
|
||||
if (unsubscribeRef.current) {
|
||||
unsubscribeRef.current();
|
||||
unsubscribeRef.current = null;
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
setError("Authentication API not available");
|
||||
setAuthState("error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for token (only available for Claude)
|
||||
const resultToken =
|
||||
cliType === "claude" && "token" in result ? result.token : undefined;
|
||||
const resultTerminalOpened =
|
||||
cliType === "claude" && "terminalOpened" in result
|
||||
? result.terminalOpened
|
||||
: false;
|
||||
|
||||
if (result.success && resultToken && typeof resultToken === "string") {
|
||||
setToken(resultToken);
|
||||
setAuthState("success");
|
||||
setOutput((prev) => [
|
||||
...prev,
|
||||
"",
|
||||
"✓ Authentication successful!",
|
||||
"✓ Token captured automatically.",
|
||||
]);
|
||||
} else if (result.requiresManualAuth) {
|
||||
// Terminal was opened - user needs to copy token manually
|
||||
setAuthState("manual");
|
||||
// Don't add extra messages if terminalOpened - the progress messages already explain
|
||||
if (!resultTerminalOpened) {
|
||||
const extraMessages = [
|
||||
"",
|
||||
"⚠ Could not capture token automatically.",
|
||||
];
|
||||
if (result.error) {
|
||||
extraMessages.push(result.error);
|
||||
}
|
||||
setOutput((prev) => [
|
||||
...prev,
|
||||
...extraMessages,
|
||||
"Please copy the token from above and paste it below.",
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
setError(result.error || "Authentication failed");
|
||||
setAuthState("error");
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
// Cleanup subscription
|
||||
if (unsubscribeRef.current) {
|
||||
unsubscribeRef.current();
|
||||
unsubscribeRef.current = null;
|
||||
}
|
||||
|
||||
const errorMessage =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: typeof err === "object" && err !== null && "error" in err
|
||||
? String((err as { error: unknown }).error)
|
||||
: "Authentication failed";
|
||||
|
||||
// Check if we should fall back to manual mode
|
||||
if (
|
||||
typeof err === "object" &&
|
||||
err !== null &&
|
||||
"requiresManualAuth" in err &&
|
||||
(err as { requiresManualAuth: boolean }).requiresManualAuth
|
||||
) {
|
||||
setAuthState("manual");
|
||||
setOutput((prev) => [
|
||||
...prev,
|
||||
"",
|
||||
"⚠ " + errorMessage,
|
||||
"Please copy the token manually and paste it below.",
|
||||
]);
|
||||
} else {
|
||||
setError(errorMessage);
|
||||
setAuthState("error");
|
||||
}
|
||||
}
|
||||
}, [cliType]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setAuthState("idle");
|
||||
setOutput([]);
|
||||
setToken("");
|
||||
setError(null);
|
||||
if (unsubscribeRef.current) {
|
||||
unsubscribeRef.current();
|
||||
unsubscribeRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { authState, output, token, error, startAuth, reset };
|
||||
}
|
||||
@@ -1,602 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { useSetupStore } from "@/store/setup-store";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import {
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
Terminal,
|
||||
Key,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
ExternalLink,
|
||||
Copy,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
Download,
|
||||
Shield,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { SetupTokenModal } from "../dialogs";
|
||||
import { StatusBadge, TerminalOutput } from "../components";
|
||||
import {
|
||||
useCliStatus,
|
||||
useCliInstallation,
|
||||
useTokenSave,
|
||||
} from "../hooks";
|
||||
|
||||
interface ClaudeSetupStepProps {
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
onSkip: () => void;
|
||||
}
|
||||
|
||||
// Claude Setup Step - 2 Authentication Options:
|
||||
// 1. OAuth Token (Subscription): User runs `claude setup-token` and provides the token
|
||||
// 2. API Key (Pay-per-use): User provides their Anthropic API key directly
|
||||
export function ClaudeSetupStep({
|
||||
onNext,
|
||||
onBack,
|
||||
onSkip,
|
||||
}: ClaudeSetupStepProps) {
|
||||
const {
|
||||
claudeCliStatus,
|
||||
claudeAuthStatus,
|
||||
setClaudeCliStatus,
|
||||
setClaudeAuthStatus,
|
||||
setClaudeInstallProgress,
|
||||
} = useSetupStore();
|
||||
const { setApiKeys, apiKeys } = useAppStore();
|
||||
|
||||
const [authMethod, setAuthMethod] = useState<"token" | "api_key" | null>(null);
|
||||
const [oauthToken, setOAuthToken] = useState("");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [showTokenModal, setShowTokenModal] = useState(false);
|
||||
|
||||
// Memoize API functions to prevent infinite loops
|
||||
const statusApi = useCallback(
|
||||
() => getElectronAPI().setup?.getClaudeStatus() || Promise.reject(),
|
||||
[]
|
||||
);
|
||||
|
||||
const installApi = useCallback(
|
||||
() => getElectronAPI().setup?.installClaude() || Promise.reject(),
|
||||
[]
|
||||
);
|
||||
|
||||
const getStoreState = useCallback(
|
||||
() => useSetupStore.getState().claudeCliStatus,
|
||||
[]
|
||||
);
|
||||
|
||||
// Use custom hooks
|
||||
const { isChecking, checkStatus } = useCliStatus({
|
||||
cliType: "claude",
|
||||
statusApi,
|
||||
setCliStatus: setClaudeCliStatus,
|
||||
setAuthStatus: setClaudeAuthStatus,
|
||||
});
|
||||
|
||||
const onInstallSuccess = useCallback(() => {
|
||||
checkStatus();
|
||||
}, [checkStatus]);
|
||||
|
||||
const { isInstalling, installProgress, install } = useCliInstallation({
|
||||
cliType: "claude",
|
||||
installApi,
|
||||
onProgressEvent: getElectronAPI().setup?.onInstallProgress,
|
||||
onSuccess: onInstallSuccess,
|
||||
getStoreState,
|
||||
});
|
||||
|
||||
const { isSaving: isSavingOAuth, saveToken: saveOAuthToken } = useTokenSave({
|
||||
provider: "anthropic_oauth_token",
|
||||
onSuccess: () => {
|
||||
setClaudeAuthStatus({
|
||||
authenticated: true,
|
||||
method: "oauth_token",
|
||||
hasCredentialsFile: false,
|
||||
oauthTokenValid: true,
|
||||
});
|
||||
setAuthMethod(null);
|
||||
checkStatus();
|
||||
},
|
||||
});
|
||||
|
||||
const { isSaving: isSavingApiKey, saveToken: saveApiKeyToken } = useTokenSave({
|
||||
provider: "anthropic",
|
||||
onSuccess: () => {
|
||||
setClaudeAuthStatus({
|
||||
authenticated: true,
|
||||
method: "api_key",
|
||||
hasCredentialsFile: false,
|
||||
apiKeyValid: true,
|
||||
});
|
||||
setApiKeys({ ...apiKeys, anthropic: apiKey });
|
||||
setAuthMethod(null);
|
||||
checkStatus();
|
||||
},
|
||||
});
|
||||
|
||||
// Sync install progress to store
|
||||
useEffect(() => {
|
||||
setClaudeInstallProgress({
|
||||
isInstalling,
|
||||
output: installProgress.output,
|
||||
});
|
||||
}, [isInstalling, installProgress, setClaudeInstallProgress]);
|
||||
|
||||
// Check status on mount
|
||||
useEffect(() => {
|
||||
checkStatus();
|
||||
}, [checkStatus]);
|
||||
|
||||
const copyCommand = (command: string) => {
|
||||
navigator.clipboard.writeText(command);
|
||||
toast.success("Command copied to clipboard");
|
||||
};
|
||||
|
||||
// Handle token obtained from the OAuth modal
|
||||
const handleTokenFromModal = useCallback(
|
||||
async (token: string) => {
|
||||
setOAuthToken(token);
|
||||
setShowTokenModal(false);
|
||||
await saveOAuthToken(token);
|
||||
},
|
||||
[saveOAuthToken]
|
||||
);
|
||||
|
||||
const isAuthenticated = claudeAuthStatus?.authenticated || apiKeys.anthropic;
|
||||
|
||||
const getAuthMethodLabel = () => {
|
||||
if (!isAuthenticated) return null;
|
||||
if (
|
||||
claudeAuthStatus?.method === "oauth_token_env" ||
|
||||
claudeAuthStatus?.method === "oauth_token"
|
||||
)
|
||||
return "Subscription Token";
|
||||
if (
|
||||
apiKeys.anthropic ||
|
||||
claudeAuthStatus?.method === "api_key" ||
|
||||
claudeAuthStatus?.method === "api_key_env"
|
||||
)
|
||||
return "API Key";
|
||||
return "Authenticated";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 rounded-xl bg-brand-500/10 flex items-center justify-center mx-auto mb-4">
|
||||
<Terminal className="w-8 h-8 text-brand-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-foreground mb-2">
|
||||
Claude Setup
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Configure Claude for code generation
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Status Card */}
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg">Status</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={checkStatus}
|
||||
disabled={isChecking}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`w-4 h-4 ${isChecking ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-foreground">CLI Installation</span>
|
||||
{isChecking ? (
|
||||
<StatusBadge status="checking" label="Checking..." />
|
||||
) : claudeCliStatus?.installed ? (
|
||||
<StatusBadge status="installed" label="Installed" />
|
||||
) : (
|
||||
<StatusBadge status="not_installed" label="Not Installed" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{claudeCliStatus?.version && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Version</span>
|
||||
<span className="text-sm font-mono text-foreground">
|
||||
{claudeCliStatus.version}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-foreground">Authentication</span>
|
||||
{isAuthenticated ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status="authenticated" label="Authenticated" />
|
||||
{getAuthMethodLabel() && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({getAuthMethodLabel()})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<StatusBadge
|
||||
status="not_authenticated"
|
||||
label="Not Authenticated"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Installation Section */}
|
||||
{!claudeCliStatus?.installed && (
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Download className="w-5 h-5" />
|
||||
Install Claude CLI
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Required for subscription-based authentication
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-muted-foreground">
|
||||
macOS / Linux
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
|
||||
curl -fsSL https://claude.ai/install.sh | bash
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
copyCommand(
|
||||
"curl -fsSL https://claude.ai/install.sh | bash"
|
||||
)
|
||||
}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-muted-foreground">Windows</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
|
||||
irm https://claude.ai/install.ps1 | iex
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
copyCommand("irm https://claude.ai/install.ps1 | iex")
|
||||
}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isInstalling && (
|
||||
<TerminalOutput lines={installProgress.output} />
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={install}
|
||||
disabled={isInstalling}
|
||||
className="w-full bg-brand-500 hover:bg-brand-600 text-white"
|
||||
data-testid="install-claude-button"
|
||||
>
|
||||
{isInstalling ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Installing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Auto Install
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Authentication Section */}
|
||||
{!isAuthenticated && (
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Key className="w-5 h-5" />
|
||||
Authentication
|
||||
</CardTitle>
|
||||
<CardDescription>Choose your authentication method</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Option 1: Subscription Token */}
|
||||
{authMethod === "token" ? (
|
||||
<div className="p-4 rounded-lg bg-brand-500/5 border border-brand-500/20 space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Shield className="w-5 h-5 text-brand-500 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-foreground">
|
||||
Subscription Token
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Use your Claude subscription (no API charges)
|
||||
</p>
|
||||
|
||||
{claudeCliStatus?.installed ? (
|
||||
<>
|
||||
{/* Primary: Automated OAuth setup */}
|
||||
<Button
|
||||
onClick={() => setShowTokenModal(true)}
|
||||
className="w-full bg-brand-500 hover:bg-brand-600 text-white mb-4"
|
||||
data-testid="setup-oauth-button"
|
||||
>
|
||||
<Terminal className="w-4 h-4 mr-2" />
|
||||
Setup with OAuth
|
||||
</Button>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="relative my-4">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t border-border" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-brand-500/5 px-2 text-muted-foreground">
|
||||
or paste manually
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fallback: Manual token entry */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-foreground text-sm">
|
||||
Paste token from{" "}
|
||||
<code className="bg-muted px-1 py-0.5 rounded text-xs">
|
||||
claude setup-token
|
||||
</code>
|
||||
:
|
||||
</Label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Paste token here..."
|
||||
value={oauthToken}
|
||||
onChange={(e) => setOAuthToken(e.target.value)}
|
||||
className="bg-input border-border text-foreground"
|
||||
data-testid="oauth-token-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setAuthMethod(null)}
|
||||
className="border-border"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => saveOAuthToken(oauthToken)}
|
||||
disabled={isSavingOAuth || !oauthToken.trim()}
|
||||
className="flex-1 bg-brand-500 hover:bg-brand-600 text-white"
|
||||
data-testid="save-oauth-token-button"
|
||||
>
|
||||
{isSavingOAuth ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
"Save Token"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="p-3 rounded bg-yellow-500/10 border border-yellow-500/20">
|
||||
<p className="text-sm text-yellow-600">
|
||||
<AlertCircle className="w-4 h-4 inline mr-1" />
|
||||
Install Claude CLI first to use subscription
|
||||
authentication
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : authMethod === "api_key" ? (
|
||||
/* Option 2: API Key */
|
||||
<div className="p-4 rounded-lg bg-green-500/5 border border-green-500/20 space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Key className="w-5 h-5 text-green-500 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-foreground">API Key</p>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Pay-per-use with your Anthropic API key
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="anthropic-key"
|
||||
className="text-foreground"
|
||||
>
|
||||
Anthropic API Key
|
||||
</Label>
|
||||
<Input
|
||||
id="anthropic-key"
|
||||
type="password"
|
||||
placeholder="sk-ant-..."
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
className="bg-input border-border text-foreground"
|
||||
data-testid="anthropic-api-key-input"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Get your API key from{" "}
|
||||
<a
|
||||
href="https://console.anthropic.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-brand-500 hover:underline"
|
||||
>
|
||||
console.anthropic.com
|
||||
<ExternalLink className="w-3 h-3 inline ml-1" />
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setAuthMethod(null)}
|
||||
className="border-border"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => saveApiKeyToken(apiKey)}
|
||||
disabled={isSavingApiKey || !apiKey.trim()}
|
||||
className="flex-1 bg-green-500 hover:bg-green-600 text-white"
|
||||
data-testid="save-anthropic-key-button"
|
||||
>
|
||||
{isSavingApiKey ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
"Save API Key"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Auth Method Selection */
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<button
|
||||
onClick={() => setAuthMethod("token")}
|
||||
className="p-4 rounded-lg border border-border hover:border-brand-500/50 bg-card hover:bg-brand-500/5 transition-all text-left"
|
||||
data-testid="select-subscription-auth"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Shield className="w-6 h-6 text-brand-500" />
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
Subscription
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Use your Claude subscription
|
||||
</p>
|
||||
<p className="text-xs text-brand-500 mt-2">
|
||||
No API charges
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setAuthMethod("api_key")}
|
||||
className="p-4 rounded-lg border border-border hover:border-green-500/50 bg-card hover:bg-green-500/5 transition-all text-left"
|
||||
data-testid="select-api-key-auth"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Key className="w-6 h-6 text-green-500" />
|
||||
<div>
|
||||
<p className="font-medium text-foreground">API Key</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Use Anthropic API key
|
||||
</p>
|
||||
<p className="text-xs text-green-500 mt-2">Pay-per-use</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Success State */}
|
||||
{isAuthenticated && (
|
||||
<Card className="bg-green-500/5 border-green-500/20">
|
||||
<CardContent className="py-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-full bg-green-500/10 flex items-center justify-center">
|
||||
<CheckCircle2 className="w-6 h-6 text-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
Claude is ready to use!
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{getAuthMethodLabel() && `Using ${getAuthMethodLabel()}. `}You
|
||||
can proceed to the next step
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onBack}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onSkip}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
Skip for now
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onNext}
|
||||
className="bg-brand-500 hover:bg-brand-600 text-white"
|
||||
data-testid="claude-next-button"
|
||||
>
|
||||
Continue
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* OAuth Setup Modal */}
|
||||
<SetupTokenModal
|
||||
open={showTokenModal}
|
||||
onClose={() => setShowTokenModal(false)}
|
||||
onTokenObtained={handleTokenFromModal}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,445 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { useSetupStore } from "@/store/setup-store";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import {
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
Terminal,
|
||||
Key,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
ExternalLink,
|
||||
Copy,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
Download,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { StatusBadge, TerminalOutput } from "../components";
|
||||
import {
|
||||
useCliStatus,
|
||||
useCliInstallation,
|
||||
useTokenSave,
|
||||
} from "../hooks";
|
||||
|
||||
interface CodexSetupStepProps {
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
onSkip: () => void;
|
||||
}
|
||||
|
||||
export function CodexSetupStep({
|
||||
onNext,
|
||||
onBack,
|
||||
onSkip,
|
||||
}: CodexSetupStepProps) {
|
||||
const {
|
||||
codexCliStatus,
|
||||
codexAuthStatus,
|
||||
setCodexCliStatus,
|
||||
setCodexAuthStatus,
|
||||
setCodexInstallProgress,
|
||||
} = useSetupStore();
|
||||
const { setApiKeys, apiKeys } = useAppStore();
|
||||
|
||||
const [showApiKeyInput, setShowApiKeyInput] = useState(false);
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
|
||||
// Memoize API functions to prevent infinite loops
|
||||
const statusApi = useCallback(
|
||||
() => getElectronAPI().setup?.getCodexStatus() || Promise.reject(),
|
||||
[]
|
||||
);
|
||||
|
||||
const installApi = useCallback(
|
||||
() => getElectronAPI().setup?.installCodex() || Promise.reject(),
|
||||
[]
|
||||
);
|
||||
|
||||
// Use custom hooks
|
||||
const { isChecking, checkStatus } = useCliStatus({
|
||||
cliType: "codex",
|
||||
statusApi,
|
||||
setCliStatus: setCodexCliStatus,
|
||||
setAuthStatus: setCodexAuthStatus,
|
||||
});
|
||||
|
||||
const onInstallSuccess = useCallback(() => {
|
||||
checkStatus();
|
||||
}, [checkStatus]);
|
||||
|
||||
const { isInstalling, installProgress, install } = useCliInstallation({
|
||||
cliType: "codex",
|
||||
installApi,
|
||||
onProgressEvent: getElectronAPI().setup?.onInstallProgress,
|
||||
onSuccess: onInstallSuccess,
|
||||
});
|
||||
|
||||
const { isSaving: isSavingKey, saveToken: saveApiKeyToken } = useTokenSave({
|
||||
provider: "openai",
|
||||
onSuccess: () => {
|
||||
setCodexAuthStatus({
|
||||
authenticated: true,
|
||||
method: "api_key",
|
||||
apiKeyValid: true,
|
||||
});
|
||||
setApiKeys({ ...apiKeys, openai: apiKey });
|
||||
setShowApiKeyInput(false);
|
||||
checkStatus();
|
||||
},
|
||||
});
|
||||
|
||||
// Sync install progress to store
|
||||
useEffect(() => {
|
||||
setCodexInstallProgress({
|
||||
isInstalling,
|
||||
output: installProgress.output,
|
||||
});
|
||||
}, [isInstalling, installProgress, setCodexInstallProgress]);
|
||||
|
||||
// Check status on mount
|
||||
useEffect(() => {
|
||||
checkStatus();
|
||||
}, [checkStatus]);
|
||||
|
||||
const copyCommand = (command: string) => {
|
||||
navigator.clipboard.writeText(command);
|
||||
toast.success("Command copied to clipboard");
|
||||
};
|
||||
|
||||
const isAuthenticated = codexAuthStatus?.authenticated || apiKeys.openai;
|
||||
|
||||
const getAuthMethodLabel = () => {
|
||||
if (!isAuthenticated) return null;
|
||||
if (apiKeys.openai) return "API Key (Manual)";
|
||||
if (codexAuthStatus?.method === "api_key") return "API Key (Auth File)";
|
||||
if (codexAuthStatus?.method === "env") return "API Key (Environment)";
|
||||
if (codexAuthStatus?.method === "cli_verified")
|
||||
return "CLI Login (ChatGPT)";
|
||||
return "Authenticated";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 rounded-xl bg-green-500/10 flex items-center justify-center mx-auto mb-4">
|
||||
<Terminal className="w-8 h-8 text-green-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-foreground mb-2">
|
||||
Codex CLI Setup
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
OpenAI's GPT-5.1 Codex for advanced code generation
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Status Card */}
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg">Installation Status</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={checkStatus}
|
||||
disabled={isChecking}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`w-4 h-4 ${isChecking ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-foreground">CLI Installation</span>
|
||||
{isChecking ? (
|
||||
<StatusBadge status="checking" label="Checking..." />
|
||||
) : codexCliStatus?.installed ? (
|
||||
<StatusBadge status="installed" label="Installed" />
|
||||
) : (
|
||||
<StatusBadge status="not_installed" label="Not Installed" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{codexCliStatus?.version && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Version</span>
|
||||
<span className="text-sm font-mono text-foreground">
|
||||
{codexCliStatus.version}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-foreground">Authentication</span>
|
||||
{isAuthenticated ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status="authenticated" label="Authenticated" />
|
||||
{getAuthMethodLabel() && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({getAuthMethodLabel()})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<StatusBadge
|
||||
status="not_authenticated"
|
||||
label="Not Authenticated"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Installation Section */}
|
||||
{!codexCliStatus?.installed && (
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Download className="w-5 h-5" />
|
||||
Install Codex CLI
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Install via npm (Node.js required)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-muted-foreground">
|
||||
npm (Global installation)
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
|
||||
npm install -g @openai/codex
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => copyCommand("npm install -g @openai/codex")}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isInstalling && (
|
||||
<TerminalOutput lines={installProgress.output} />
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={install}
|
||||
disabled={isInstalling}
|
||||
className="flex-1 bg-green-500 hover:bg-green-600 text-white"
|
||||
data-testid="install-codex-button"
|
||||
>
|
||||
{isInstalling ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Installing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Auto Install
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-yellow-500 mt-0.5" />
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-400">
|
||||
Requires Node.js to be installed. If the auto-install fails,
|
||||
try running the command manually in your terminal.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Authentication Section */}
|
||||
{!isAuthenticated && (
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Key className="w-5 h-5" />
|
||||
Authentication
|
||||
</CardTitle>
|
||||
<CardDescription>Codex requires an OpenAI API key</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{codexCliStatus?.installed && (
|
||||
<div className="p-4 rounded-lg bg-muted/50 border border-border">
|
||||
<div className="flex items-start gap-3">
|
||||
<Terminal className="w-5 h-5 text-green-500 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
Authenticate via CLI
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Run this command in your terminal:
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="bg-muted px-3 py-1 rounded text-sm font-mono text-foreground">
|
||||
codex auth login
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => copyCommand("codex auth login")}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t border-border" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-card px-2 text-muted-foreground">
|
||||
or enter API key
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showApiKeyInput ? (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="openai-key" className="text-foreground">
|
||||
OpenAI API Key
|
||||
</Label>
|
||||
<Input
|
||||
id="openai-key"
|
||||
type="password"
|
||||
placeholder="sk-..."
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
className="bg-input border-border text-foreground"
|
||||
data-testid="openai-api-key-input"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Get your API key from{" "}
|
||||
<a
|
||||
href="https://platform.openai.com/api-keys"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-green-500 hover:underline"
|
||||
>
|
||||
platform.openai.com
|
||||
<ExternalLink className="w-3 h-3 inline ml-1" />
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowApiKeyInput(false)}
|
||||
className="border-border"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => saveApiKeyToken(apiKey)}
|
||||
disabled={isSavingKey || !apiKey.trim()}
|
||||
className="flex-1 bg-green-500 hover:bg-green-600 text-white"
|
||||
data-testid="save-openai-key-button"
|
||||
>
|
||||
{isSavingKey ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
"Save API Key"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowApiKeyInput(true)}
|
||||
className="w-full border-border"
|
||||
data-testid="use-openai-key-button"
|
||||
>
|
||||
<Key className="w-4 h-4 mr-2" />
|
||||
Enter OpenAI API Key
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Success State */}
|
||||
{isAuthenticated && (
|
||||
<Card className="bg-green-500/5 border-green-500/20">
|
||||
<CardContent className="py-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-full bg-green-500/10 flex items-center justify-center">
|
||||
<CheckCircle2 className="w-6 h-6 text-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
Codex is ready to use!
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{getAuthMethodLabel() &&
|
||||
`Authenticated via ${getAuthMethodLabel()}. `}
|
||||
You can proceed to complete setup
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onBack}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onSkip}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
Skip for now
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onNext}
|
||||
className="bg-green-500 hover:bg-green-600 text-white"
|
||||
data-testid="codex-next-button"
|
||||
>
|
||||
Continue
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Shield,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { useSetupStore } from "@/store/setup-store";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
|
||||
interface CompleteStepProps {
|
||||
onFinish: () => void;
|
||||
}
|
||||
|
||||
export function CompleteStep({ onFinish }: CompleteStepProps) {
|
||||
const { claudeCliStatus, claudeAuthStatus, codexCliStatus, codexAuthStatus } =
|
||||
useSetupStore();
|
||||
const { apiKeys } = useAppStore();
|
||||
|
||||
const claudeReady =
|
||||
(claudeCliStatus?.installed && claudeAuthStatus?.authenticated) ||
|
||||
apiKeys.anthropic;
|
||||
const codexReady =
|
||||
(codexCliStatus?.installed && codexAuthStatus?.authenticated) ||
|
||||
apiKeys.openai;
|
||||
|
||||
return (
|
||||
<div className="text-center space-y-6">
|
||||
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-green-500 to-emerald-600 shadow-lg shadow-green-500/30 flex items-center justify-center mx-auto">
|
||||
<CheckCircle2 className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-foreground mb-3">
|
||||
Setup Complete!
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-md mx-auto">
|
||||
Your development environment is configured. You're ready to start
|
||||
building with AI-powered assistance.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 max-w-2xl mx-auto">
|
||||
<Card
|
||||
className={`bg-card/50 border ${
|
||||
claudeReady ? "border-green-500/50" : "border-yellow-500/50"
|
||||
}`}
|
||||
>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{claudeReady ? (
|
||||
<CheckCircle2 className="w-6 h-6 text-green-500" />
|
||||
) : (
|
||||
<AlertCircle className="w-6 h-6 text-yellow-500" />
|
||||
)}
|
||||
<div className="text-left">
|
||||
<p className="font-medium text-foreground">Claude</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{claudeReady ? "Ready to use" : "Configure later in settings"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className={`bg-card/50 border ${
|
||||
codexReady ? "border-green-500/50" : "border-yellow-500/50"
|
||||
}`}
|
||||
>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{codexReady ? (
|
||||
<CheckCircle2 className="w-6 h-6 text-green-500" />
|
||||
) : (
|
||||
<AlertCircle className="w-6 h-6 text-yellow-500" />
|
||||
)}
|
||||
<div className="text-left">
|
||||
<p className="font-medium text-foreground">Codex</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{codexReady ? "Ready to use" : "Configure later in settings"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg bg-muted/50 border border-border max-w-md mx-auto">
|
||||
<div className="flex items-start gap-3">
|
||||
<Shield className="w-5 h-5 text-brand-500 mt-0.5" />
|
||||
<div className="text-left">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Your credentials are secure
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
API keys are stored locally and never sent to our servers
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white"
|
||||
onClick={onFinish}
|
||||
data-testid="setup-finish-button"
|
||||
>
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
Start Building
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Terminal, ArrowRight } from "lucide-react";
|
||||
|
||||
interface WelcomeStepProps {
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
export function WelcomeStep({ onNext }: WelcomeStepProps) {
|
||||
return (
|
||||
<div className="text-center space-y-6">
|
||||
<div className="flex items-center justify-center mx-auto">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src="/logo.png" alt="Automaker Logo" className="w-24 h-24" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-foreground mb-3">
|
||||
Welcome to Automaker
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-md mx-auto">
|
||||
Let's set up your development environment. We'll check for
|
||||
required CLI tools and help you configure them.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 max-w-2xl mx-auto">
|
||||
<Card className="bg-card/50 border-border hover:border-brand-500/50 transition-colors">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Terminal className="w-5 h-5 text-brand-500" />
|
||||
Claude CLI
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Anthropic's powerful AI assistant for code generation and
|
||||
analysis
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card/50 border-border hover:border-brand-500/50 transition-colors">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Terminal className="w-5 h-5 text-green-500" />
|
||||
Codex CLI
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
OpenAI's GPT-5.1 Codex for advanced code generation tasks
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white"
|
||||
onClick={onNext}
|
||||
data-testid="setup-start-button"
|
||||
>
|
||||
Get Started
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,971 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Save, RefreshCw, FileText, Sparkles, Loader2, FilePlus2, AlertCircle, ListPlus, CheckCircle2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { XmlSyntaxEditor } from "@/components/ui/xml-syntax-editor";
|
||||
import type { SpecRegenerationEvent } from "@/types/electron";
|
||||
|
||||
// Delay before reloading spec file to ensure it's written to disk
|
||||
const SPEC_FILE_WRITE_DELAY = 500;
|
||||
|
||||
// Interval for polling backend status during generation
|
||||
const STATUS_CHECK_INTERVAL_MS = 2000;
|
||||
|
||||
export function SpecView() {
|
||||
const { currentProject, appSpec, setAppSpec } = useAppStore();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [specExists, setSpecExists] = useState(true);
|
||||
|
||||
// Regeneration state
|
||||
const [showRegenerateDialog, setShowRegenerateDialog] = useState(false);
|
||||
const [projectDefinition, setProjectDefinition] = useState("");
|
||||
const [isRegenerating, setIsRegenerating] = useState(false);
|
||||
|
||||
// Create spec state
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
const [projectOverview, setProjectOverview] = useState("");
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [generateFeatures, setGenerateFeatures] = useState(true);
|
||||
|
||||
// Generate features only state
|
||||
const [isGeneratingFeatures, setIsGeneratingFeatures] = useState(false);
|
||||
|
||||
// Logs state (kept for internal tracking, but UI removed)
|
||||
const [logs, setLogs] = useState<string>("");
|
||||
const logsRef = useRef<string>("");
|
||||
|
||||
// Phase tracking and status
|
||||
const [currentPhase, setCurrentPhase] = useState<string>("");
|
||||
const [errorMessage, setErrorMessage] = useState<string>("");
|
||||
const statusCheckRef = useRef<boolean>(false);
|
||||
const stateRestoredRef = useRef<boolean>(false);
|
||||
|
||||
// Load spec from file
|
||||
const loadSpec = useCallback(async () => {
|
||||
if (!currentProject) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const result = await api.readFile(
|
||||
`${currentProject.path}/.automaker/app_spec.txt`
|
||||
);
|
||||
|
||||
if (result.success && result.content) {
|
||||
setAppSpec(result.content);
|
||||
setSpecExists(true);
|
||||
setHasChanges(false);
|
||||
} else {
|
||||
// File doesn't exist
|
||||
setAppSpec("");
|
||||
setSpecExists(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load spec:", error);
|
||||
setSpecExists(false);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [currentProject, setAppSpec]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSpec();
|
||||
}, [loadSpec]);
|
||||
|
||||
// Check if spec regeneration is running when component mounts or project changes
|
||||
useEffect(() => {
|
||||
const checkStatus = async () => {
|
||||
if (!currentProject || statusCheckRef.current) return;
|
||||
statusCheckRef.current = true;
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.specRegeneration) {
|
||||
statusCheckRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const status = await api.specRegeneration.status();
|
||||
console.log("[SpecView] Status check on mount:", status);
|
||||
|
||||
if (status.success && status.isRunning) {
|
||||
// Something is running - restore state using backend's authoritative phase
|
||||
console.log("[SpecView] Spec generation is running - restoring state", { phase: status.currentPhase });
|
||||
|
||||
if (!stateRestoredRef.current) {
|
||||
setIsCreating(true);
|
||||
setIsRegenerating(true);
|
||||
stateRestoredRef.current = true;
|
||||
}
|
||||
|
||||
// Use the backend's currentPhase directly - single source of truth
|
||||
if (status.currentPhase) {
|
||||
setCurrentPhase(status.currentPhase);
|
||||
} else {
|
||||
setCurrentPhase("in progress");
|
||||
}
|
||||
|
||||
// Add resume message to logs if needed
|
||||
if (!logsRef.current) {
|
||||
const resumeMessage = "[Status] Resumed monitoring existing spec generation process...\n";
|
||||
logsRef.current = resumeMessage;
|
||||
setLogs(resumeMessage);
|
||||
} else if (!logsRef.current.includes("Resumed monitoring")) {
|
||||
const resumeMessage = "\n[Status] Resumed monitoring existing spec generation process...\n";
|
||||
logsRef.current = logsRef.current + resumeMessage;
|
||||
setLogs(logsRef.current);
|
||||
}
|
||||
} else if (status.success && !status.isRunning) {
|
||||
// Not running - clear all state
|
||||
setIsCreating(false);
|
||||
setIsRegenerating(false);
|
||||
setCurrentPhase("");
|
||||
stateRestoredRef.current = false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[SpecView] Failed to check status:", error);
|
||||
} finally {
|
||||
statusCheckRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Reset restoration flag when project changes
|
||||
stateRestoredRef.current = false;
|
||||
checkStatus();
|
||||
}, [currentProject]);
|
||||
|
||||
// Sync state when tab becomes visible (user returns to spec editor)
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = async () => {
|
||||
if (!document.hidden && currentProject && (isCreating || isRegenerating || isGeneratingFeatures)) {
|
||||
// Tab became visible and we think we're still generating - verify status from backend
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.specRegeneration) return;
|
||||
|
||||
const status = await api.specRegeneration.status();
|
||||
console.log("[SpecView] Visibility change - status check:", status);
|
||||
|
||||
if (!status.isRunning) {
|
||||
// Backend says not running - clear state
|
||||
console.log("[SpecView] Visibility change: Backend indicates generation complete - clearing state");
|
||||
setIsCreating(false);
|
||||
setIsRegenerating(false);
|
||||
setIsGeneratingFeatures(false);
|
||||
setCurrentPhase("");
|
||||
stateRestoredRef.current = false;
|
||||
loadSpec();
|
||||
} else if (status.currentPhase) {
|
||||
// Still running - update phase from backend
|
||||
setCurrentPhase(status.currentPhase);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[SpecView] Failed to check status on visibility change:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [currentProject, isCreating, isRegenerating, isGeneratingFeatures, loadSpec]);
|
||||
|
||||
// Periodic status check to ensure state stays in sync (only when we think we're running)
|
||||
useEffect(() => {
|
||||
if (!currentProject || (!isCreating && !isRegenerating && !isGeneratingFeatures)) return;
|
||||
|
||||
const intervalId = setInterval(async () => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.specRegeneration) return;
|
||||
|
||||
const status = await api.specRegeneration.status();
|
||||
|
||||
if (!status.isRunning) {
|
||||
// Backend says not running - clear state
|
||||
console.log("[SpecView] Periodic check: Backend indicates generation complete - clearing state");
|
||||
setIsCreating(false);
|
||||
setIsRegenerating(false);
|
||||
setIsGeneratingFeatures(false);
|
||||
setCurrentPhase("");
|
||||
stateRestoredRef.current = false;
|
||||
loadSpec();
|
||||
} else if (status.currentPhase && status.currentPhase !== currentPhase) {
|
||||
// Still running but phase changed - update from backend
|
||||
console.log("[SpecView] Periodic check: Phase updated from backend", {
|
||||
old: currentPhase,
|
||||
new: status.currentPhase
|
||||
});
|
||||
setCurrentPhase(status.currentPhase);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[SpecView] Periodic status check error:", error);
|
||||
}
|
||||
}, STATUS_CHECK_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [currentProject, isCreating, isRegenerating, isGeneratingFeatures, currentPhase, loadSpec]);
|
||||
|
||||
// Subscribe to spec regeneration events
|
||||
useEffect(() => {
|
||||
const api = getElectronAPI();
|
||||
if (!api.specRegeneration) return;
|
||||
|
||||
const unsubscribe = api.specRegeneration.onEvent((event: SpecRegenerationEvent) => {
|
||||
console.log("[SpecView] Regeneration event:", event.type);
|
||||
|
||||
if (event.type === "spec_regeneration_progress") {
|
||||
// Extract phase from content if present
|
||||
const phaseMatch = event.content.match(/\[Phase:\s*([^\]]+)\]/);
|
||||
if (phaseMatch) {
|
||||
const phase = phaseMatch[1];
|
||||
setCurrentPhase(phase);
|
||||
console.log(`[SpecView] Phase updated: ${phase}`);
|
||||
|
||||
// If phase is "complete", clear running state immediately
|
||||
if (phase === "complete") {
|
||||
console.log("[SpecView] Phase is complete - clearing state");
|
||||
setIsCreating(false);
|
||||
setIsRegenerating(false);
|
||||
stateRestoredRef.current = false;
|
||||
// Small delay to ensure spec file is written
|
||||
setTimeout(() => {
|
||||
loadSpec();
|
||||
}, SPEC_FILE_WRITE_DELAY);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for completion indicators in content
|
||||
if (event.content.includes("All tasks completed") ||
|
||||
event.content.includes("✓ All tasks completed")) {
|
||||
// This indicates everything is done - clear state immediately
|
||||
console.log("[SpecView] Detected completion in progress message - clearing state");
|
||||
setIsCreating(false);
|
||||
setIsRegenerating(false);
|
||||
setCurrentPhase("");
|
||||
stateRestoredRef.current = false;
|
||||
setTimeout(() => {
|
||||
loadSpec();
|
||||
}, SPEC_FILE_WRITE_DELAY);
|
||||
}
|
||||
|
||||
// Append progress to logs
|
||||
const newLog = logsRef.current + event.content;
|
||||
logsRef.current = newLog;
|
||||
setLogs(newLog);
|
||||
console.log("[SpecView] Progress:", event.content.substring(0, 100));
|
||||
|
||||
// Clear error message when we get new progress
|
||||
if (errorMessage) {
|
||||
setErrorMessage("");
|
||||
}
|
||||
} else if (event.type === "spec_regeneration_tool") {
|
||||
// Check if this is a feature creation tool
|
||||
const isFeatureTool = event.tool === "mcp__automaker-tools__UpdateFeatureStatus" ||
|
||||
event.tool === "UpdateFeatureStatus" ||
|
||||
event.tool?.includes("Feature");
|
||||
|
||||
if (isFeatureTool) {
|
||||
// Ensure we're in feature generation phase
|
||||
if (currentPhase !== "feature_generation") {
|
||||
setCurrentPhase("feature_generation");
|
||||
setIsCreating(true);
|
||||
setIsRegenerating(true);
|
||||
console.log("[SpecView] Detected feature creation tool - setting phase to feature_generation");
|
||||
}
|
||||
}
|
||||
|
||||
// Log tool usage with details
|
||||
const toolInput = event.input ? ` (${JSON.stringify(event.input).substring(0, 100)}...)` : "";
|
||||
const toolLog = `\n[Tool] ${event.tool}${toolInput}\n`;
|
||||
const newLog = logsRef.current + toolLog;
|
||||
logsRef.current = newLog;
|
||||
setLogs(newLog);
|
||||
console.log("[SpecView] Tool:", event.tool, event.input);
|
||||
} else if (event.type === "spec_regeneration_complete") {
|
||||
// Add completion message to logs first
|
||||
const completionLog = logsRef.current + `\n[Complete] ${event.message}\n`;
|
||||
logsRef.current = completionLog;
|
||||
setLogs(completionLog);
|
||||
|
||||
// --- Completion Detection Logic ---
|
||||
// The backend sends explicit signals for completion:
|
||||
// 1. "All tasks completed" in the message
|
||||
// 2. [Phase: complete] marker in logs
|
||||
// 3. "Spec regeneration complete!" for regeneration
|
||||
// 4. "Initial spec creation complete!" for creation without features
|
||||
const isFinalCompletionMessage = event.message?.includes("All tasks completed") ||
|
||||
event.message === "All tasks completed!" ||
|
||||
event.message === "All tasks completed" ||
|
||||
event.message === "Spec regeneration complete!" ||
|
||||
event.message === "Initial spec creation complete!";
|
||||
|
||||
const hasCompletePhase = logsRef.current.includes("[Phase: complete]");
|
||||
|
||||
// Intermediate completion means features are being generated after spec creation
|
||||
const isIntermediateCompletion = event.message?.includes("Features are being generated") ||
|
||||
event.message?.includes("features are being generated");
|
||||
|
||||
// Rely solely on explicit backend signals
|
||||
const shouldComplete = (isFinalCompletionMessage || hasCompletePhase) && !isIntermediateCompletion;
|
||||
|
||||
if (shouldComplete) {
|
||||
// Fully complete - clear all states immediately
|
||||
console.log("[SpecView] Final completion detected - clearing state", {
|
||||
isFinalCompletionMessage,
|
||||
hasCompletePhase,
|
||||
message: event.message
|
||||
});
|
||||
setIsRegenerating(false);
|
||||
setIsCreating(false);
|
||||
setIsGeneratingFeatures(false);
|
||||
setCurrentPhase("");
|
||||
setShowRegenerateDialog(false);
|
||||
setShowCreateDialog(false);
|
||||
setProjectDefinition("");
|
||||
setProjectOverview("");
|
||||
setErrorMessage("");
|
||||
stateRestoredRef.current = false;
|
||||
|
||||
// Reload the spec with delay to ensure file is written to disk
|
||||
setTimeout(() => {
|
||||
loadSpec();
|
||||
}, SPEC_FILE_WRITE_DELAY);
|
||||
|
||||
// Show success toast notification
|
||||
const isRegeneration = event.message?.includes("regeneration");
|
||||
const isFeatureGeneration = event.message?.includes("Feature generation");
|
||||
toast.success(
|
||||
isFeatureGeneration
|
||||
? "Feature Generation Complete"
|
||||
: isRegeneration
|
||||
? "Spec Regeneration Complete"
|
||||
: "Spec Creation Complete",
|
||||
{
|
||||
description: isFeatureGeneration
|
||||
? "Features have been created from the app specification."
|
||||
: "Your app specification has been saved.",
|
||||
icon: <CheckCircle2 className="w-4 h-4" />,
|
||||
}
|
||||
);
|
||||
} else if (isIntermediateCompletion) {
|
||||
// Intermediate completion - keep state active for feature generation
|
||||
setIsCreating(true);
|
||||
setIsRegenerating(true);
|
||||
setCurrentPhase("feature_generation");
|
||||
console.log("[SpecView] Intermediate completion, continuing with feature generation");
|
||||
}
|
||||
|
||||
console.log("[SpecView] Spec generation event:", event.message);
|
||||
} else if (event.type === "spec_regeneration_error") {
|
||||
setIsRegenerating(false);
|
||||
setIsCreating(false);
|
||||
setIsGeneratingFeatures(false);
|
||||
setCurrentPhase("error");
|
||||
setErrorMessage(event.error);
|
||||
stateRestoredRef.current = false; // Reset restoration flag
|
||||
// Add error to logs
|
||||
const errorLog = logsRef.current + `\n\n[ERROR] ${event.error}\n`;
|
||||
logsRef.current = errorLog;
|
||||
setLogs(errorLog);
|
||||
console.error("[SpecView] Regeneration error:", event.error);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [loadSpec]);
|
||||
|
||||
// Save spec to file
|
||||
const saveSpec = async () => {
|
||||
if (!currentProject) return;
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
await api.writeFile(
|
||||
`${currentProject.path}/.automaker/app_spec.txt`,
|
||||
appSpec
|
||||
);
|
||||
setHasChanges(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to save spec:", error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
setAppSpec(value);
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleRegenerate = async () => {
|
||||
if (!currentProject || !projectDefinition.trim()) return;
|
||||
|
||||
setIsRegenerating(true);
|
||||
setCurrentPhase("initialization");
|
||||
setErrorMessage("");
|
||||
// Reset logs when starting new regeneration
|
||||
logsRef.current = "";
|
||||
setLogs("");
|
||||
console.log("[SpecView] Starting spec regeneration");
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.specRegeneration) {
|
||||
console.error("[SpecView] Spec regeneration not available");
|
||||
setIsRegenerating(false);
|
||||
return;
|
||||
}
|
||||
const result = await api.specRegeneration.generate(
|
||||
currentProject.path,
|
||||
projectDefinition.trim()
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
const errorMsg = result.error || "Unknown error";
|
||||
console.error("[SpecView] Failed to start regeneration:", errorMsg);
|
||||
setIsRegenerating(false);
|
||||
setCurrentPhase("error");
|
||||
setErrorMessage(errorMsg);
|
||||
const errorLog = `[Error] Failed to start regeneration: ${errorMsg}\n`;
|
||||
logsRef.current = errorLog;
|
||||
setLogs(errorLog);
|
||||
}
|
||||
// If successful, we'll wait for the events to update the state
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
console.error("[SpecView] Failed to regenerate spec:", errorMsg);
|
||||
setIsRegenerating(false);
|
||||
setCurrentPhase("error");
|
||||
setErrorMessage(errorMsg);
|
||||
const errorLog = `[Error] Failed to regenerate spec: ${errorMsg}\n`;
|
||||
logsRef.current = errorLog;
|
||||
setLogs(errorLog);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateSpec = async () => {
|
||||
if (!currentProject || !projectOverview.trim()) return;
|
||||
|
||||
setIsCreating(true);
|
||||
setShowCreateDialog(false);
|
||||
setCurrentPhase("initialization");
|
||||
setErrorMessage("");
|
||||
// Reset logs when starting new generation
|
||||
logsRef.current = "";
|
||||
setLogs("");
|
||||
console.log("[SpecView] Starting spec creation, generateFeatures:", generateFeatures);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.specRegeneration) {
|
||||
console.error("[SpecView] Spec regeneration not available");
|
||||
setIsCreating(false);
|
||||
return;
|
||||
}
|
||||
const result = await api.specRegeneration.create(
|
||||
currentProject.path,
|
||||
projectOverview.trim(),
|
||||
generateFeatures
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
const errorMsg = result.error || "Unknown error";
|
||||
console.error("[SpecView] Failed to start spec creation:", errorMsg);
|
||||
setIsCreating(false);
|
||||
setCurrentPhase("error");
|
||||
setErrorMessage(errorMsg);
|
||||
const errorLog = `[Error] Failed to start spec creation: ${errorMsg}\n`;
|
||||
logsRef.current = errorLog;
|
||||
setLogs(errorLog);
|
||||
}
|
||||
// If successful, we'll wait for the events to update the state
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
console.error("[SpecView] Failed to create spec:", errorMsg);
|
||||
setIsCreating(false);
|
||||
setCurrentPhase("error");
|
||||
setErrorMessage(errorMsg);
|
||||
const errorLog = `[Error] Failed to create spec: ${errorMsg}\n`;
|
||||
logsRef.current = errorLog;
|
||||
setLogs(errorLog);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateFeatures = async () => {
|
||||
if (!currentProject) return;
|
||||
|
||||
setIsGeneratingFeatures(true);
|
||||
setShowRegenerateDialog(false);
|
||||
setCurrentPhase("initialization");
|
||||
setErrorMessage("");
|
||||
// Reset logs when starting feature generation
|
||||
logsRef.current = "";
|
||||
setLogs("");
|
||||
console.log("[SpecView] Starting feature generation from existing spec");
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.specRegeneration) {
|
||||
console.error("[SpecView] Spec regeneration not available");
|
||||
setIsGeneratingFeatures(false);
|
||||
return;
|
||||
}
|
||||
const result = await api.specRegeneration.generateFeatures(
|
||||
currentProject.path
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
const errorMsg = result.error || "Unknown error";
|
||||
console.error("[SpecView] Failed to start feature generation:", errorMsg);
|
||||
setIsGeneratingFeatures(false);
|
||||
setCurrentPhase("error");
|
||||
setErrorMessage(errorMsg);
|
||||
const errorLog = `[Error] Failed to start feature generation: ${errorMsg}\n`;
|
||||
logsRef.current = errorLog;
|
||||
setLogs(errorLog);
|
||||
}
|
||||
// If successful, we'll wait for the events to update the state
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
console.error("[SpecView] Failed to generate features:", errorMsg);
|
||||
setIsGeneratingFeatures(false);
|
||||
setCurrentPhase("error");
|
||||
setErrorMessage(errorMsg);
|
||||
const errorLog = `[Error] Failed to generate features: ${errorMsg}\n`;
|
||||
logsRef.current = errorLog;
|
||||
setLogs(errorLog);
|
||||
}
|
||||
};
|
||||
|
||||
if (!currentProject) {
|
||||
return (
|
||||
<div
|
||||
className="flex-1 flex items-center justify-center"
|
||||
data-testid="spec-view-no-project"
|
||||
>
|
||||
<p className="text-muted-foreground">No project selected</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
className="flex-1 flex items-center justify-center"
|
||||
data-testid="spec-view-loading"
|
||||
>
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show empty state when no spec exists (isCreating is handled by bottom-right indicator in sidebar)
|
||||
if (!specExists) {
|
||||
return (
|
||||
<div
|
||||
className="flex-1 flex flex-col overflow-hidden content-bg"
|
||||
data-testid="spec-view-empty"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="w-5 h-5 text-muted-foreground" />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">App Specification</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{currentProject.path}/.automaker/app_spec.txt
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{(isCreating || isRegenerating) && (
|
||||
<div className="flex items-center gap-3 px-6 py-3.5 rounded-xl bg-gradient-to-r from-primary/15 to-primary/5 border border-primary/30 shadow-lg backdrop-blur-md">
|
||||
<div className="relative">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-primary flex-shrink-0" />
|
||||
<div className="absolute inset-0 w-5 h-5 animate-ping text-primary/20" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<span className="text-sm font-semibold text-primary leading-tight tracking-tight">
|
||||
{isCreating ? "Generating Specification" : "Regenerating Specification"}
|
||||
</span>
|
||||
{currentPhase && (
|
||||
<span className="text-xs text-muted-foreground/90 leading-tight font-medium">
|
||||
{currentPhase === "initialization" && "Initializing..."}
|
||||
{currentPhase === "setup" && "Setting up tools..."}
|
||||
{currentPhase === "analysis" && "Analyzing project structure..."}
|
||||
{currentPhase === "spec_complete" && "Spec created! Generating features..."}
|
||||
{currentPhase === "feature_generation" && "Creating features from roadmap..."}
|
||||
{currentPhase === "complete" && "Complete!"}
|
||||
{currentPhase === "error" && "Error occurred"}
|
||||
{!["initialization", "setup", "analysis", "spec_complete", "feature_generation", "complete", "error"].includes(currentPhase) && currentPhase}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{errorMessage && (
|
||||
<div className="flex items-center gap-2 text-destructive">
|
||||
<span className="text-sm font-medium">Error: {errorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Empty State */}
|
||||
<div className="flex-1 flex items-center justify-center p-8">
|
||||
<div className="text-center max-w-md">
|
||||
<div className="mb-6 flex justify-center">
|
||||
<div className="p-4 rounded-full bg-primary/10">
|
||||
{isCreating ? (
|
||||
<Loader2 className="w-12 h-12 text-primary animate-spin" />
|
||||
) : (
|
||||
<FilePlus2 className="w-12 h-12 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold mb-4">
|
||||
{isCreating ? (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<span>Generating App Specification</span>
|
||||
</div>
|
||||
{currentPhase && (
|
||||
<div className="px-6 py-3.5 rounded-xl bg-gradient-to-r from-primary/15 to-primary/5 border border-primary/30 shadow-lg backdrop-blur-md inline-flex items-center justify-center">
|
||||
<span className="text-sm font-semibold text-primary text-center tracking-tight">
|
||||
{currentPhase === "initialization" && "Initializing..."}
|
||||
{currentPhase === "setup" && "Setting up tools..."}
|
||||
{currentPhase === "analysis" && "Analyzing project structure..."}
|
||||
{currentPhase === "spec_complete" && "Spec created! Generating features..."}
|
||||
{currentPhase === "feature_generation" && "Creating features from roadmap..."}
|
||||
{currentPhase === "complete" && "Complete!"}
|
||||
{currentPhase === "error" && "Error occurred"}
|
||||
{!["initialization", "setup", "analysis", "spec_complete", "feature_generation", "complete", "error"].includes(currentPhase) && currentPhase}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
"No App Specification Found"
|
||||
)}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
{isCreating
|
||||
? currentPhase === "feature_generation"
|
||||
? "The app specification has been created! Now generating features from the implementation roadmap..."
|
||||
: "We're analyzing your project and generating a comprehensive specification. This may take a few moments..."
|
||||
: "Create an app specification to help our system understand your project. We'll analyze your codebase and generate a comprehensive spec based on your description."}
|
||||
</p>
|
||||
{errorMessage && (
|
||||
<div className="mb-4 p-3 rounded-md bg-destructive/10 border border-destructive/20">
|
||||
<p className="text-sm text-destructive font-medium">Error:</p>
|
||||
<p className="text-sm text-destructive">{errorMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
{!isCreating && (
|
||||
<div className="flex gap-2 justify-center">
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={() => setShowCreateDialog(true)}
|
||||
>
|
||||
<FilePlus2 className="w-5 h-5 mr-2" />
|
||||
Create app_spec
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Dialog */}
|
||||
<Dialog
|
||||
open={showCreateDialog}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !isCreating) {
|
||||
setShowCreateDialog(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create App Specification</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
We didn't find an app_spec.txt file. Let us help you generate your app_spec.txt
|
||||
to help describe your project for our system. We'll analyze your project's
|
||||
tech stack and create a comprehensive specification.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
Project Overview
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Describe what your project does and what features you want to build.
|
||||
Be as detailed as you want - this will help us create a better specification.
|
||||
</p>
|
||||
<textarea
|
||||
className="w-full h-48 p-3 rounded-md border border-border bg-background font-mono text-sm resize-none focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
value={projectOverview}
|
||||
onChange={(e) => setProjectOverview(e.target.value)}
|
||||
placeholder="e.g., A project management tool that allows teams to track tasks, manage sprints, and visualize progress through kanban boards. It should support user authentication, real-time updates, and file attachments..."
|
||||
autoFocus
|
||||
disabled={isCreating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3 pt-2">
|
||||
<Checkbox
|
||||
id="generate-features"
|
||||
checked={generateFeatures}
|
||||
onCheckedChange={(checked) => setGenerateFeatures(checked === true)}
|
||||
disabled={isCreating}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor="generate-features"
|
||||
className={`text-sm font-medium ${isCreating ? "" : "cursor-pointer"}`}
|
||||
>
|
||||
Generate feature list
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Automatically create features in the features folder from the
|
||||
implementation roadmap after the spec is generated.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setShowCreateDialog(false)}
|
||||
disabled={isCreating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<HotkeyButton
|
||||
onClick={handleCreateSpec}
|
||||
disabled={!projectOverview.trim() || isCreating}
|
||||
hotkey={{ key: "Enter", cmdCtrl: true }}
|
||||
hotkeyActive={showCreateDialog && !isCreating}
|
||||
>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
Generate Spec
|
||||
</>
|
||||
)}
|
||||
</HotkeyButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-1 flex flex-col overflow-hidden content-bg"
|
||||
data-testid="spec-view"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="w-5 h-5 text-muted-foreground" />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">App Specification</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{currentProject.path}/.automaker/app_spec.txt
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{(isRegenerating || isCreating || isGeneratingFeatures) && (
|
||||
<div className="flex items-center gap-3 px-6 py-3.5 rounded-xl bg-gradient-to-r from-primary/15 to-primary/5 border border-primary/30 shadow-lg backdrop-blur-md">
|
||||
<div className="relative">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-primary flex-shrink-0" />
|
||||
<div className="absolute inset-0 w-5 h-5 animate-ping text-primary/20" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<span className="text-sm font-semibold text-primary leading-tight tracking-tight">
|
||||
{isGeneratingFeatures ? "Generating Features" : isCreating ? "Generating Specification" : "Regenerating Specification"}
|
||||
</span>
|
||||
{currentPhase && (
|
||||
<span className="text-xs text-muted-foreground/90 leading-tight font-medium">
|
||||
{currentPhase === "initialization" && "Initializing..."}
|
||||
{currentPhase === "setup" && "Setting up tools..."}
|
||||
{currentPhase === "analysis" && "Analyzing project structure..."}
|
||||
{currentPhase === "spec_complete" && "Spec created! Generating features..."}
|
||||
{currentPhase === "feature_generation" && "Creating features from roadmap..."}
|
||||
{currentPhase === "complete" && "Complete!"}
|
||||
{currentPhase === "error" && "Error occurred"}
|
||||
{!["initialization", "setup", "analysis", "spec_complete", "feature_generation", "complete", "error"].includes(currentPhase) && currentPhase}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{errorMessage && (
|
||||
<div className="flex items-center gap-3 px-6 py-3.5 rounded-xl bg-gradient-to-r from-destructive/15 to-destructive/5 border border-destructive/30 shadow-lg backdrop-blur-md">
|
||||
<AlertCircle className="w-5 h-5 text-destructive flex-shrink-0" />
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<span className="text-sm font-semibold text-destructive leading-tight tracking-tight">Error</span>
|
||||
<span className="text-xs text-destructive/90 leading-tight font-medium">{errorMessage}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setShowRegenerateDialog(true)}
|
||||
disabled={isRegenerating || isCreating || isGeneratingFeatures}
|
||||
data-testid="regenerate-spec"
|
||||
>
|
||||
{isRegenerating ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{isRegenerating ? "Regenerating..." : "Regenerate"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={saveSpec}
|
||||
disabled={!hasChanges || isSaving || isCreating || isRegenerating || isGeneratingFeatures}
|
||||
data-testid="save-spec"
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{isSaving ? "Saving..." : hasChanges ? "Save Changes" : "Saved"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
<div className="flex-1 p-4 overflow-hidden">
|
||||
<Card className="h-full overflow-hidden">
|
||||
<XmlSyntaxEditor
|
||||
value={appSpec}
|
||||
onChange={handleChange}
|
||||
placeholder="Write your app specification here..."
|
||||
data-testid="spec-editor"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Regenerate Dialog */}
|
||||
<Dialog
|
||||
open={showRegenerateDialog}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !isRegenerating) {
|
||||
setShowRegenerateDialog(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Regenerate App Specification</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
We will regenerate your app spec based on a short project definition and the
|
||||
current tech stack found in your project. The agent will analyze your codebase
|
||||
to understand your existing technologies and create a comprehensive specification.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
Describe your project
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Provide a clear description of what your app should do. Be as detailed as you
|
||||
want - the more context you provide, the more comprehensive the spec will be.
|
||||
</p>
|
||||
<textarea
|
||||
className="w-full h-40 p-3 rounded-md border border-border bg-background font-mono text-sm resize-none focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
value={projectDefinition}
|
||||
onChange={(e) => setProjectDefinition(e.target.value)}
|
||||
placeholder="e.g., A task management app where users can create projects, add tasks with due dates, assign tasks to team members, track progress with a kanban board, and receive notifications for upcoming deadlines..."
|
||||
disabled={isRegenerating}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex justify-between sm:justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleGenerateFeatures}
|
||||
disabled={isRegenerating || isGeneratingFeatures}
|
||||
title="Generate features from the existing app_spec.txt without regenerating the spec"
|
||||
>
|
||||
{isGeneratingFeatures ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ListPlus className="w-4 h-4 mr-2" />
|
||||
Generate Features
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setShowRegenerateDialog(false)}
|
||||
disabled={isRegenerating || isGeneratingFeatures}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<HotkeyButton
|
||||
onClick={handleRegenerate}
|
||||
disabled={!projectDefinition.trim() || isRegenerating || isGeneratingFeatures}
|
||||
hotkey={{ key: "Enter", cmdCtrl: true }}
|
||||
hotkeyActive={showRegenerateDialog && !isRegenerating && !isGeneratingFeatures}
|
||||
>
|
||||
{isRegenerating ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Regenerating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
Regenerate Spec
|
||||
</>
|
||||
)}
|
||||
</HotkeyButton>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
/**
|
||||
* Marketing mode flag
|
||||
* When set to true, displays "https://automaker.app" with "maker" in theme color
|
||||
*/
|
||||
|
||||
export const IS_MARKETING = process.env.NEXT_PUBLIC_IS_MARKETING === "true";
|
||||
@@ -1,434 +0,0 @@
|
||||
/**
|
||||
* Log Parser Utility
|
||||
* Parses agent output into structured sections for display
|
||||
*/
|
||||
|
||||
export type LogEntryType =
|
||||
| "prompt"
|
||||
| "tool_call"
|
||||
| "tool_result"
|
||||
| "phase"
|
||||
| "error"
|
||||
| "success"
|
||||
| "info"
|
||||
| "debug"
|
||||
| "warning"
|
||||
| "thinking";
|
||||
|
||||
export interface LogEntry {
|
||||
id: string;
|
||||
type: LogEntryType;
|
||||
title: string;
|
||||
content: string;
|
||||
timestamp?: string;
|
||||
collapsed?: boolean;
|
||||
metadata?: {
|
||||
toolName?: string;
|
||||
phase?: string;
|
||||
[key: string]: string | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a deterministic ID based on content and position
|
||||
* This ensures the same log entry always gets the same ID,
|
||||
* preserving expanded/collapsed state when new logs stream in
|
||||
*
|
||||
* Uses only the first 200 characters of content to ensure stability
|
||||
* even when entries are merged (which appends content at the end)
|
||||
*/
|
||||
const generateDeterministicId = (content: string, lineIndex: number): string => {
|
||||
// Use first 200 chars to ensure stability when entries are merged
|
||||
const stableContent = content.slice(0, 200);
|
||||
// Simple hash function for the content
|
||||
let hash = 0;
|
||||
const str = stableContent + '|' + lineIndex.toString();
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash; // Convert to 32bit integer
|
||||
}
|
||||
return 'log_' + Math.abs(hash).toString(36);
|
||||
};
|
||||
|
||||
/**
|
||||
* Detects the type of log entry based on content patterns
|
||||
*/
|
||||
function detectEntryType(content: string): LogEntryType {
|
||||
const trimmed = content.trim();
|
||||
|
||||
// Tool calls
|
||||
if (trimmed.startsWith("🔧 Tool:") || trimmed.match(/^Tool:\s*/)) {
|
||||
return "tool_call";
|
||||
}
|
||||
|
||||
// Tool results / Input
|
||||
if (trimmed.startsWith("Input:") || trimmed.startsWith("Result:") || trimmed.startsWith("Output:")) {
|
||||
return "tool_result";
|
||||
}
|
||||
|
||||
// Phase changes
|
||||
if (
|
||||
trimmed.startsWith("📋") ||
|
||||
trimmed.startsWith("⚡") ||
|
||||
trimmed.startsWith("✅") ||
|
||||
trimmed.match(/^(Planning|Action|Verification)/i) ||
|
||||
trimmed.match(/\[Phase:\s*([^\]]+)\]/) ||
|
||||
trimmed.match(/Phase:\s*\w+/i)
|
||||
) {
|
||||
return "phase";
|
||||
}
|
||||
|
||||
// Feature creation events
|
||||
if (
|
||||
trimmed.match(/\[Feature Creation\]/i) ||
|
||||
trimmed.match(/Feature Creation/i) ||
|
||||
trimmed.match(/Creating feature/i)
|
||||
) {
|
||||
return "success";
|
||||
}
|
||||
|
||||
// Errors
|
||||
if (trimmed.startsWith("❌") || trimmed.toLowerCase().includes("error:")) {
|
||||
return "error";
|
||||
}
|
||||
|
||||
// Success messages
|
||||
if (
|
||||
trimmed.startsWith("✅") ||
|
||||
trimmed.toLowerCase().includes("success") ||
|
||||
trimmed.toLowerCase().includes("completed")
|
||||
) {
|
||||
return "success";
|
||||
}
|
||||
|
||||
// Warnings
|
||||
if (trimmed.startsWith("⚠️") || trimmed.toLowerCase().includes("warning:")) {
|
||||
return "warning";
|
||||
}
|
||||
|
||||
// Thinking/Preparation info
|
||||
if (
|
||||
trimmed.toLowerCase().includes("ultrathink") ||
|
||||
trimmed.toLowerCase().includes("thinking level") ||
|
||||
trimmed.toLowerCase().includes("estimated cost") ||
|
||||
trimmed.toLowerCase().includes("estimated time") ||
|
||||
trimmed.toLowerCase().includes("budget tokens") ||
|
||||
trimmed.match(/thinking.*preparation/i)
|
||||
) {
|
||||
return "thinking";
|
||||
}
|
||||
|
||||
// Debug info (JSON, stack traces, etc.)
|
||||
if (
|
||||
trimmed.startsWith("{") ||
|
||||
trimmed.startsWith("[") ||
|
||||
trimmed.includes("at ") ||
|
||||
trimmed.match(/^\s*\d+\s*\|/)
|
||||
) {
|
||||
return "debug";
|
||||
}
|
||||
|
||||
// Default to info
|
||||
return "info";
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts tool name from a tool call entry
|
||||
*/
|
||||
function extractToolName(content: string): string | undefined {
|
||||
const match = content.match(/🔧\s*Tool:\s*(\S+)/);
|
||||
return match?.[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts phase name from a phase entry
|
||||
*/
|
||||
function extractPhase(content: string): string | undefined {
|
||||
if (content.includes("📋")) return "planning";
|
||||
if (content.includes("⚡")) return "action";
|
||||
if (content.includes("✅")) return "verification";
|
||||
|
||||
// Extract from [Phase: ...] format
|
||||
const phaseMatch = content.match(/\[Phase:\s*([^\]]+)\]/);
|
||||
if (phaseMatch) {
|
||||
return phaseMatch[1].toLowerCase();
|
||||
}
|
||||
|
||||
const match = content.match(/^(Planning|Action|Verification)/i);
|
||||
return match?.[1]?.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a title for a log entry
|
||||
*/
|
||||
function generateTitle(type: LogEntryType, content: string): string {
|
||||
switch (type) {
|
||||
case "tool_call": {
|
||||
const toolName = extractToolName(content);
|
||||
return toolName ? `Tool Call: ${toolName}` : "Tool Call";
|
||||
}
|
||||
case "tool_result":
|
||||
return "Tool Input/Result";
|
||||
case "phase": {
|
||||
const phase = extractPhase(content);
|
||||
if (phase) {
|
||||
// Capitalize first letter of each word
|
||||
const formatted = phase.split(/\s+/).map(word =>
|
||||
word.charAt(0).toUpperCase() + word.slice(1)
|
||||
).join(" ");
|
||||
return `Phase: ${formatted}`;
|
||||
}
|
||||
return "Phase Change";
|
||||
}
|
||||
case "error":
|
||||
return "Error";
|
||||
case "success":
|
||||
return "Success";
|
||||
case "warning":
|
||||
return "Warning";
|
||||
case "thinking":
|
||||
return "Thinking Level";
|
||||
case "debug":
|
||||
return "Debug Info";
|
||||
case "prompt":
|
||||
return "Prompt";
|
||||
default:
|
||||
return "Info";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses raw log output into structured entries
|
||||
*/
|
||||
export function parseLogOutput(rawOutput: string): LogEntry[] {
|
||||
if (!rawOutput || !rawOutput.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries: LogEntry[] = [];
|
||||
const lines = rawOutput.split("\n");
|
||||
|
||||
let currentEntry: Omit<LogEntry, 'id'> & { id?: string } | null = null;
|
||||
let currentContent: string[] = [];
|
||||
let entryStartLine = 0; // Track the starting line for deterministic ID generation
|
||||
|
||||
const finalizeEntry = () => {
|
||||
if (currentEntry && currentContent.length > 0) {
|
||||
currentEntry.content = currentContent.join("\n").trim();
|
||||
if (currentEntry.content) {
|
||||
// Generate deterministic ID based on content and position
|
||||
const entryWithId: LogEntry = {
|
||||
...currentEntry as Omit<LogEntry, 'id'>,
|
||||
id: generateDeterministicId(currentEntry.content, entryStartLine),
|
||||
};
|
||||
entries.push(entryWithId);
|
||||
}
|
||||
}
|
||||
currentContent = [];
|
||||
};
|
||||
|
||||
let lineIndex = 0;
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
// Skip empty lines at the beginning
|
||||
if (!trimmedLine && !currentEntry) {
|
||||
lineIndex++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Detect if this line starts a new entry
|
||||
const lineType = detectEntryType(trimmedLine);
|
||||
const isNewEntry =
|
||||
trimmedLine.startsWith("🔧") ||
|
||||
trimmedLine.startsWith("📋") ||
|
||||
trimmedLine.startsWith("⚡") ||
|
||||
trimmedLine.startsWith("✅") ||
|
||||
trimmedLine.startsWith("❌") ||
|
||||
trimmedLine.startsWith("⚠️") ||
|
||||
trimmedLine.startsWith("🧠") ||
|
||||
trimmedLine.match(/\[Phase:\s*([^\]]+)\]/) ||
|
||||
trimmedLine.match(/\[Feature Creation\]/i) ||
|
||||
trimmedLine.match(/\[Tool\]/i) ||
|
||||
trimmedLine.match(/\[Agent\]/i) ||
|
||||
trimmedLine.match(/\[Complete\]/i) ||
|
||||
trimmedLine.match(/\[ERROR\]/i) ||
|
||||
trimmedLine.match(/\[Status\]/i) ||
|
||||
trimmedLine.toLowerCase().includes("ultrathink preparation") ||
|
||||
trimmedLine.toLowerCase().includes("thinking level") ||
|
||||
(trimmedLine.startsWith("Input:") && currentEntry?.type === "tool_call");
|
||||
|
||||
if (isNewEntry) {
|
||||
// Finalize previous entry
|
||||
finalizeEntry();
|
||||
|
||||
// Track starting line for deterministic ID
|
||||
entryStartLine = lineIndex;
|
||||
|
||||
// Start new entry (ID will be generated when finalizing)
|
||||
currentEntry = {
|
||||
type: lineType,
|
||||
title: generateTitle(lineType, trimmedLine),
|
||||
content: "",
|
||||
metadata: {
|
||||
toolName: extractToolName(trimmedLine),
|
||||
phase: extractPhase(trimmedLine),
|
||||
},
|
||||
};
|
||||
currentContent.push(trimmedLine);
|
||||
} else if (currentEntry) {
|
||||
// Continue current entry
|
||||
currentContent.push(line);
|
||||
} else {
|
||||
// Track starting line for deterministic ID
|
||||
entryStartLine = lineIndex;
|
||||
|
||||
// No current entry, create a default info entry
|
||||
currentEntry = {
|
||||
type: "info",
|
||||
title: "Info",
|
||||
content: "",
|
||||
};
|
||||
currentContent.push(line);
|
||||
}
|
||||
lineIndex++;
|
||||
}
|
||||
|
||||
// Finalize last entry
|
||||
finalizeEntry();
|
||||
|
||||
// Merge consecutive entries of the same type if they're both debug or info
|
||||
const mergedEntries = mergeConsecutiveEntries(entries);
|
||||
|
||||
return mergedEntries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges consecutive entries of the same type for cleaner display
|
||||
*/
|
||||
function mergeConsecutiveEntries(entries: LogEntry[]): LogEntry[] {
|
||||
if (entries.length <= 1) return entries;
|
||||
|
||||
const merged: LogEntry[] = [];
|
||||
let current: LogEntry | null = null;
|
||||
let mergeIndex = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (
|
||||
current &&
|
||||
(current.type === "debug" || current.type === "info") &&
|
||||
current.type === entry.type
|
||||
) {
|
||||
// Merge into current - regenerate ID based on merged content
|
||||
current.content += "\n\n" + entry.content;
|
||||
current.id = generateDeterministicId(current.content, mergeIndex);
|
||||
} else {
|
||||
if (current) {
|
||||
merged.push(current);
|
||||
}
|
||||
current = { ...entry };
|
||||
mergeIndex = merged.length;
|
||||
}
|
||||
}
|
||||
|
||||
if (current) {
|
||||
merged.push(current);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the color classes for a log entry type
|
||||
*/
|
||||
export function getLogTypeColors(type: LogEntryType): {
|
||||
bg: string;
|
||||
border: string;
|
||||
text: string;
|
||||
icon: string;
|
||||
badge: string;
|
||||
} {
|
||||
switch (type) {
|
||||
case "prompt":
|
||||
return {
|
||||
bg: "bg-blue-500/10",
|
||||
border: "border-l-blue-500",
|
||||
text: "text-blue-300",
|
||||
icon: "text-blue-400",
|
||||
badge: "bg-blue-500/20 text-blue-300",
|
||||
};
|
||||
case "tool_call":
|
||||
return {
|
||||
bg: "bg-amber-500/10",
|
||||
border: "border-l-amber-500",
|
||||
text: "text-amber-300",
|
||||
icon: "text-amber-400",
|
||||
badge: "bg-amber-500/20 text-amber-300",
|
||||
};
|
||||
case "tool_result":
|
||||
return {
|
||||
bg: "bg-slate-500/10",
|
||||
border: "border-l-slate-400",
|
||||
text: "text-slate-300",
|
||||
icon: "text-slate-400",
|
||||
badge: "bg-slate-500/20 text-slate-300",
|
||||
};
|
||||
case "phase":
|
||||
return {
|
||||
bg: "bg-cyan-500/10",
|
||||
border: "border-l-cyan-500",
|
||||
text: "text-cyan-300",
|
||||
icon: "text-cyan-400",
|
||||
badge: "bg-cyan-500/20 text-cyan-300",
|
||||
};
|
||||
case "error":
|
||||
return {
|
||||
bg: "bg-red-500/10",
|
||||
border: "border-l-red-500",
|
||||
text: "text-red-300",
|
||||
icon: "text-red-400",
|
||||
badge: "bg-red-500/20 text-red-300",
|
||||
};
|
||||
case "success":
|
||||
return {
|
||||
bg: "bg-emerald-500/10",
|
||||
border: "border-l-emerald-500",
|
||||
text: "text-emerald-300",
|
||||
icon: "text-emerald-400",
|
||||
badge: "bg-emerald-500/20 text-emerald-300",
|
||||
};
|
||||
case "warning":
|
||||
return {
|
||||
bg: "bg-orange-500/10",
|
||||
border: "border-l-orange-500",
|
||||
text: "text-orange-300",
|
||||
icon: "text-orange-400",
|
||||
badge: "bg-orange-500/20 text-orange-300",
|
||||
};
|
||||
case "thinking":
|
||||
return {
|
||||
bg: "bg-indigo-500/10",
|
||||
border: "border-l-indigo-500",
|
||||
text: "text-indigo-300",
|
||||
icon: "text-indigo-400",
|
||||
badge: "bg-indigo-500/20 text-indigo-300",
|
||||
};
|
||||
case "debug":
|
||||
return {
|
||||
bg: "bg-primary/10",
|
||||
border: "border-l-primary",
|
||||
text: "text-primary",
|
||||
icon: "text-primary",
|
||||
badge: "bg-primary/20 text-primary",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
bg: "bg-zinc-500/10",
|
||||
border: "border-l-zinc-500",
|
||||
text: "text-zinc-300",
|
||||
icon: "text-zinc-400",
|
||||
badge: "bg-zinc-500/20 text-zinc-300",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import type { AgentModel } from "@/store/app-store"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model is a Codex/OpenAI model (doesn't support thinking)
|
||||
*/
|
||||
export function isCodexModel(model?: AgentModel | string): boolean {
|
||||
if (!model) return false;
|
||||
const codexModels: string[] = [
|
||||
"gpt-5.1-codex-max",
|
||||
"gpt-5.1-codex",
|
||||
"gpt-5.1-codex-mini",
|
||||
"gpt-5.1",
|
||||
];
|
||||
return codexModels.includes(model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the current model supports extended thinking controls
|
||||
*/
|
||||
export function modelSupportsThinking(model?: AgentModel | string): boolean {
|
||||
if (!model) return true;
|
||||
return !isCodexModel(model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display name for a model
|
||||
*/
|
||||
export function getModelDisplayName(model: AgentModel | string): string {
|
||||
const displayNames: Record<string, string> = {
|
||||
haiku: "Claude Haiku",
|
||||
sonnet: "Claude Sonnet",
|
||||
opus: "Claude Opus",
|
||||
"gpt-5.1-codex-max": "GPT-5.1 Codex Max",
|
||||
"gpt-5.1-codex": "GPT-5.1 Codex",
|
||||
"gpt-5.1-codex-mini": "GPT-5.1 Codex Mini",
|
||||
"gpt-5.1": "GPT-5.1",
|
||||
};
|
||||
return displayNames[model] || model;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy the public directory to nginx's html directory
|
||||
COPY public/ /usr/share/nginx/html/
|
||||
|
||||
# Expose port 80
|
||||
EXPOSE 80
|
||||
|
||||
# Start nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"name": "@automaker/marketing",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "serve public -l 3000",
|
||||
"start": "serve public -l 3000"
|
||||
},
|
||||
"devDependencies": {
|
||||
"serve": "^14.2.4"
|
||||
}
|
||||
}
|
||||
@@ -1,594 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Automaker - Autonomous AI Development Studio</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--primary: #6366f1;
|
||||
--primary-dark: #4f46e5;
|
||||
--secondary: #8b5cf6;
|
||||
--accent: #ec4899;
|
||||
--dark: #0f172a;
|
||||
--dark-light: #1e293b;
|
||||
--text: #e2e8f0;
|
||||
--text-muted: #94a3b8;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
header {
|
||||
padding: 2rem 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
backdrop-filter: blur(10px);
|
||||
z-index: 100;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, var(--primary), var(--secondary));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.nav-links a:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Hero Section */
|
||||
.hero {
|
||||
padding: 6rem 0;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hero::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
background: radial-gradient(circle, rgba(99, 102, 241, 0.1) 0%, transparent 70%);
|
||||
border-radius: 50%;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 4rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 1.5rem;
|
||||
background: linear-gradient(135deg, #ffffff 0%, var(--text-muted) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
font-size: 1.5rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 2rem;
|
||||
max-width: 700px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.cta-buttons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 1rem 2rem;
|
||||
border-radius: 0.5rem;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--primary), var(--secondary));
|
||||
color: white;
|
||||
box-shadow: 0 4px 15px rgba(99, 102, 241, 0.4);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(99, 102, 241, 0.6);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: rgba(148, 163, 184, 0.1);
|
||||
color: var(--text);
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: rgba(148, 163, 184, 0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Features Section */
|
||||
.features {
|
||||
padding: 6rem 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
text-align: center;
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 4rem;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 2rem;
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: rgba(30, 41, 59, 0.5);
|
||||
border: 1px solid rgba(148, 163, 184, 0.1);
|
||||
border-radius: 1rem;
|
||||
padding: 2rem;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
transform: translateY(-5px);
|
||||
border-color: rgba(99, 102, 241, 0.5);
|
||||
box-shadow: 0 10px 30px rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.feature-card h3 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.feature-card p {
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Tech Stack Section */
|
||||
.tech-stack {
|
||||
padding: 6rem 0;
|
||||
background: rgba(15, 23, 42, 0.5);
|
||||
}
|
||||
|
||||
.tech-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
.tech-item {
|
||||
background: rgba(30, 41, 59, 0.5);
|
||||
padding: 1rem 2rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid rgba(148, 163, 184, 0.1);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.tech-item:hover {
|
||||
border-color: var(--primary);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
footer {
|
||||
padding: 3rem 0;
|
||||
text-align: center;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.1);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
footer a {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.hero h1 {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
animation: fadeInUp 0.6s ease-out;
|
||||
}
|
||||
|
||||
.feature-card:nth-child(1) { animation-delay: 0.1s; }
|
||||
.feature-card:nth-child(2) { animation-delay: 0.2s; }
|
||||
.feature-card:nth-child(3) { animation-delay: 0.3s; }
|
||||
.feature-card:nth-child(4) { animation-delay: 0.4s; }
|
||||
.feature-card:nth-child(5) { animation-delay: 0.5s; }
|
||||
.feature-card:nth-child(6) { animation-delay: 0.6s; }
|
||||
|
||||
/* Download Buttons */
|
||||
.download-section {
|
||||
margin-top: 2.5rem;
|
||||
}
|
||||
|
||||
.download-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.download-buttons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-download {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: rgba(30, 41, 59, 0.8);
|
||||
color: var(--text);
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.btn-download:hover {
|
||||
background: rgba(99, 102, 241, 0.2);
|
||||
border-color: var(--primary);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-download svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.download-subtitle {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.download-subtitle a {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.download-subtitle a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Video Demo Section */
|
||||
.video-demo {
|
||||
margin-top: 3rem;
|
||||
max-width: 900px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
.video-container {
|
||||
position: relative;
|
||||
margin-left: -2rem;
|
||||
margin-right: -2rem;
|
||||
width: calc(100% + 4rem);
|
||||
padding-bottom: 66.67%; /* Taller aspect ratio to show more height */
|
||||
background: rgba(30, 41, 59, 0.5);
|
||||
border-radius: 1rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.video-container video {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.video-demo {
|
||||
margin-top: 2rem;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.video-container {
|
||||
margin-left: -1rem;
|
||||
margin-right: -1rem;
|
||||
width: calc(100% + 2rem);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<nav class="container">
|
||||
<div class="logo">Automaker</div>
|
||||
<ul class="nav-links">
|
||||
<li><a href="#features">Features</a></li>
|
||||
<li><a href="#tech">Tech Stack</a></li>
|
||||
<li><a href="releases.html">Releases</a></li>
|
||||
<li><a href="https://github.com/AutoMaker-Org/automaker" target="_blank">GitHub</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="hero">
|
||||
<div class="container">
|
||||
<h1>Build Software Faster with AI-Powered Agents</h1>
|
||||
<p>Automaker is an autonomous AI development studio that helps you build software faster using AI-powered agents. Manage features visually, assign AI agents automatically, and track progress through an intuitive workflow.</p>
|
||||
<div class="cta-buttons">
|
||||
<a href="https://github.com/AutoMaker-Org/automaker" class="btn btn-primary" target="_blank">View on GitHub</a>
|
||||
<a href="https://github.com/AutoMaker-Org/automaker#getting-started" class="btn btn-secondary" target="_blank">Get Started</a>
|
||||
</div>
|
||||
|
||||
<div class="video-demo">
|
||||
<div class="video-container">
|
||||
<video controls autoplay muted loop playsinline>
|
||||
<source src="https://releases.automaker.app/demo.mp4" type="video/mp4">
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="download-section" id="downloadSection" style="display: none;">
|
||||
<p class="download-label">Download for your platform:</p>
|
||||
<div class="download-buttons">
|
||||
<a href="#" class="btn-download" id="download-windows" style="display: none;">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M0 3.449L9.75 2.1v9.451H0m10.949-9.602L24 0v11.4H10.949M0 12.6h9.75v9.451L0 20.699M10.949 12.6H24V24l-12.9-1.801"/></svg>
|
||||
Windows
|
||||
</a>
|
||||
<a href="#" class="btn-download" id="download-macos" style="display: none;">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z"/></svg>
|
||||
macOS
|
||||
</a>
|
||||
<a href="#" class="btn-download" id="download-linux" style="display: none;">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12.504 0c-.155 0-.315.008-.48.021-4.226.333-3.105 4.807-3.17 6.298-.076 1.092-.3 1.953-1.05 3.02-.885 1.051-2.127 2.75-2.716 4.521-.278.832-.41 1.684-.287 2.489a.424.424 0 00-.11.135c-.26.268-.45.6-.663.839-.199.199-.485.267-.797.4-.313.136-.658.269-.864.68-.09.189-.136.394-.132.602 0 .199.027.4.055.536.058.399.116.728.04.97-.249.68-.28 1.145-.106 1.484.174.334.535.47.94.601.81.2 1.91.135 2.774.6.926.466 1.866.67 2.616.47.526-.116.97-.464 1.208-.946.587-.003 1.23-.269 2.26-.334.699-.058 1.574.267 2.577.2.025.134.063.198.114.333l.003.003c.391.778 1.113 1.132 1.884 1.071.771-.06 1.592-.536 2.257-1.306.631-.765 1.683-1.084 2.378-1.503.348-.199.629-.469.649-.853.023-.4-.2-.811-.714-1.376v-.097l-.003-.003c-.17-.2-.25-.535-.338-.926-.085-.401-.182-.786-.492-1.046h-.003c-.059-.054-.123-.067-.188-.135a.357.357 0 00-.19-.064c.431-1.278.264-2.55-.173-3.694-.533-1.41-1.465-2.638-2.175-3.483-.796-1.005-1.576-1.957-1.56-3.368.026-2.152.236-6.133-3.544-6.139zm.529 3.405h.013c.213 0 .396.062.584.198.19.135.33.332.438.533.105.259.158.459.166.724 0-.02.006-.04.006-.06v.105a.086.086 0 01-.004-.021l-.004-.024a1.807 1.807 0 01-.15.706.953.953 0 01-.213.335.71.71 0 00-.088-.042c-.104-.045-.198-.064-.284-.133a1.312 1.312 0 00-.22-.066c.05-.06.146-.133.183-.198.053-.128.082-.264.088-.402v-.02a1.21 1.21 0 00-.061-.4c-.045-.134-.101-.2-.183-.333-.084-.066-.167-.132-.267-.132h-.016c-.093 0-.176.03-.262.132a.8.8 0 00-.205.334 1.18 1.18 0 00-.09.4v.019c.002.089.008.179.02.267-.193-.067-.438-.135-.607-.202a1.635 1.635 0 01-.018-.2v-.02a1.772 1.772 0 01.15-.768c.082-.22.232-.406.43-.533a.985.985 0 01.594-.2zm-2.962.059h.036c.142 0 .27.048.399.135.146.129.264.288.344.465.09.199.14.4.153.667v.004c.007.134.006.2-.002.266v.08c-.03.007-.056.018-.083.024-.152.055-.274.135-.393.2.012-.09.013-.18.003-.267v-.015c-.012-.133-.04-.2-.082-.333a.613.613 0 00-.166-.267.248.248 0 00-.183-.064h-.021c-.071.006-.13.04-.186.132a.552.552 0 00-.12.27.944.944 0 00-.023.33v.015c.012.135.037.2.08.334.046.134.098.2.166.268.01.009.02.018.034.024-.07.057-.117.07-.176.136a.304.304 0 01-.131.068 2.62 2.62 0 01-.275-.402 1.772 1.772 0 01-.155-.667 1.759 1.759 0 01.08-.668 1.43 1.43 0 01.283-.535c.128-.133.26-.2.418-.2zm1.37 1.706c.332 0 .733.065 1.216.399.293.2.523.269 1.052.468h.003c.255.136.405.266.478.399v-.131a.571.571 0 01.016.47c-.123.31-.516.643-1.063.842v.002c-.268.135-.501.333-.775.465-.276.135-.588.292-1.012.267a1.139 1.139 0 01-.448-.067 3.566 3.566 0 01-.322-.198c-.195-.135-.363-.332-.612-.465v-.005h-.005c-.4-.246-.616-.512-.686-.71-.07-.268-.005-.47.193-.6.224-.135.38-.271.483-.336.104-.074.143-.102.176-.131h.002v-.003c.169-.202.436-.47.839-.601.139-.036.294-.065.466-.065zm2.8 2.142c.358 1.417 1.196 3.475 1.735 4.473.286.534.855 1.659 1.102 3.024.156-.005.33.018.513.064.646-1.671-.546-3.467-1.089-3.966-.22-.2-.232-.335-.123-.335.59.534 1.365 1.572 1.646 2.757.13.535.16 1.104.021 1.67.067.028.135.06.205.067 1.032.534 1.413.938 1.23 1.537v-.002c-.06-.003-.12 0-.18 0h-.016c.151-.467-.182-.825-1.065-1.224-.915-.4-1.646-.336-1.77.465-.008.043-.013.066-.018.135-.068.023-.139.053-.209.064-.43.268-.662.669-.793 1.187-.13.533-.17 1.156-.205 1.869v.003c-.02.482-.04 1.053-.158 1.425-.06.134-.133.27-.238.465h-.003c-.067-.004-.003-.401-.004-.469.006-.534.011-1.2.036-1.534.006-.468.011-.534-.021-.267-.18.936-.323 1.2-.608 1.67a1.016 1.016 0 01-.112.134v.003l-.005-.003c-.07-.2-.044-.401-.044-.535-.002-.468.006-.869-.089-1.334-.066-.468-.353-.935-.711-1.469-.074-.104-.264-.333-.376-.533-.073-.133-.067-.267.123-.336.104-.037.2-.135.29-.2.09-.067.18-.136.27-.2.02-.015.04-.018.059-.036.14-.083.267-.2.368-.335a.838.838 0 00.145-.262l.002-.004c.028-.087.042-.133.034-.2-.034-.135-.232-.333-.393-.468-.226-.2-.4-.333-.673-.467l-.005-.002c-.569-.27-1.322-.534-1.927-.8a.082.082 0 01-.026-.013c-.136-.071-.27-.2-.406-.4-.466-.735-.727-1.536-.727-1.936 0-.2.067-.4.129-.533.032-.067.065-.135.102-.2.036-.067.257-.2.378-.267.143-.095.287-.191.441-.263z"/></svg>
|
||||
Linux
|
||||
</a>
|
||||
</div>
|
||||
<p class="download-subtitle">
|
||||
<span id="latestVersion"></span> | <a href="releases.html">All releases</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="features" id="features">
|
||||
<div class="container">
|
||||
<h2 class="section-title">Powerful Features</h2>
|
||||
<p class="section-subtitle">Everything you need to accelerate your development workflow</p>
|
||||
<div class="features-grid">
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">📋</span>
|
||||
<h3>Kanban Board</h3>
|
||||
<p>Visual drag-and-drop board to manage features through backlog, in progress, waiting approval, and verified stages.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">🤖</span>
|
||||
<h3>AI Agent Integration</h3>
|
||||
<p>Automatic AI agent assignment to implement features when moved to "In Progress".</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">🧠</span>
|
||||
<h3>Multi-Model Support</h3>
|
||||
<p>Choose from multiple AI models including Claude Opus, Sonnet, and more.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">📡</span>
|
||||
<h3>Real-time Agent Output</h3>
|
||||
<p>View live agent output, logs, and file diffs as features are being implemented.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">🔍</span>
|
||||
<h3>Project Analysis</h3>
|
||||
<p>AI-powered project structure analysis to understand your codebase.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">💡</span>
|
||||
<h3>Feature Suggestions</h3>
|
||||
<p>AI-generated feature suggestions based on your project.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">⚡</span>
|
||||
<h3>Concurrent Processing</h3>
|
||||
<p>Configure concurrency to process multiple features simultaneously.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">🔀</span>
|
||||
<h3>Git Integration</h3>
|
||||
<p>View git diffs and track changes made by AI agents.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">🖥️</span>
|
||||
<h3>Cross-Platform</h3>
|
||||
<p>Desktop application built with Electron for Windows, macOS, and Linux.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="tech-stack" id="tech">
|
||||
<div class="container">
|
||||
<h2 class="section-title">Built With Modern Technology</h2>
|
||||
<p class="section-subtitle">Powered by the best tools in the industry</p>
|
||||
<div class="tech-grid">
|
||||
<div class="tech-item">Next.js</div>
|
||||
<div class="tech-item">Electron</div>
|
||||
<div class="tech-item">React</div>
|
||||
<div class="tech-item">Tailwind CSS</div>
|
||||
<div class="tech-item">Zustand</div>
|
||||
<div class="tech-item">dnd-kit</div>
|
||||
<div class="tech-item">TypeScript</div>
|
||||
<div class="tech-item">Claude AI</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="container">
|
||||
<p>Made with ❤️ by <a href="mailto:webdevcody@gmail.com">Cody Seibert</a></p>
|
||||
<p style="margin-top: 1rem;">
|
||||
<a href="https://github.com/AutoMaker-Org/automaker" target="_blank">GitHub</a> |
|
||||
<a href="https://github.com/AutoMaker-Org/automaker/blob/main/LICENSE" target="_blank">License</a>
|
||||
</p>
|
||||
<p style="margin-top: 1rem; font-size: 0.9rem; color: var(--text-muted);">
|
||||
⚠️ <strong>Security Disclaimer:</strong> This software uses AI-powered tooling that has access to your operating system. Use at your own risk. We recommend running Automaker in a sandboxed environment.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const R2_RELEASES_URL = window.RELEASES_JSON_URL || 'https://releases.automaker.app/releases.json';
|
||||
|
||||
async function loadLatestRelease() {
|
||||
try {
|
||||
const response = await fetch(R2_RELEASES_URL);
|
||||
if (!response.ok) throw new Error('Failed to fetch releases');
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.releases || data.releases.length === 0) return;
|
||||
|
||||
const latest = data.releases[0];
|
||||
let hasAnyAsset = false;
|
||||
|
||||
if (latest.assets.windows) {
|
||||
const btn = document.getElementById('download-windows');
|
||||
btn.href = latest.assets.windows.url;
|
||||
btn.style.display = 'inline-flex';
|
||||
hasAnyAsset = true;
|
||||
}
|
||||
if (latest.assets.macos || latest.assets.macosArm) {
|
||||
const btn = document.getElementById('download-macos');
|
||||
const macAsset = latest.assets.macosArm || latest.assets.macos;
|
||||
btn.href = macAsset.url;
|
||||
btn.style.display = 'inline-flex';
|
||||
hasAnyAsset = true;
|
||||
}
|
||||
if (latest.assets.linux) {
|
||||
const btn = document.getElementById('download-linux');
|
||||
btn.href = latest.assets.linux.url;
|
||||
btn.style.display = 'inline-flex';
|
||||
hasAnyAsset = true;
|
||||
}
|
||||
|
||||
if (hasAnyAsset) {
|
||||
document.getElementById('latestVersion').textContent = latest.version;
|
||||
document.getElementById('downloadSection').style.display = 'block';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load releases:', error);
|
||||
}
|
||||
}
|
||||
|
||||
loadLatestRelease();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,422 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Releases - Automaker</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--primary: #6366f1;
|
||||
--primary-dark: #4f46e5;
|
||||
--secondary: #8b5cf6;
|
||||
--accent: #ec4899;
|
||||
--dark: #0f172a;
|
||||
--dark-light: #1e293b;
|
||||
--text: #e2e8f0;
|
||||
--text-muted: #94a3b8;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
overflow-x: hidden;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
header {
|
||||
padding: 2rem 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
backdrop-filter: blur(10px);
|
||||
z-index: 100;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, var(--primary), var(--secondary));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.nav-links a:hover,
|
||||
.nav-links a.active {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Page Header */
|
||||
.page-header {
|
||||
padding: 4rem 0 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 3rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 1rem;
|
||||
background: linear-gradient(135deg, #ffffff 0%, var(--text-muted) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
font-size: 1.2rem;
|
||||
color: var(--text-muted);
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Releases Section */
|
||||
.releases-section {
|
||||
padding: 2rem 0 6rem;
|
||||
}
|
||||
|
||||
.releases-list {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.release-card {
|
||||
background: rgba(30, 41, 59, 0.5);
|
||||
border: 1px solid rgba(148, 163, 184, 0.1);
|
||||
border-radius: 1rem;
|
||||
padding: 2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.release-card:first-child {
|
||||
border-color: var(--primary);
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
||||
.release-card:hover {
|
||||
border-color: rgba(99, 102, 241, 0.5);
|
||||
}
|
||||
|
||||
.release-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.release-version {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.release-date {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.latest-badge {
|
||||
background: linear-gradient(135deg, var(--primary), var(--secondary));
|
||||
color: white;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 1rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.release-downloads {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.download-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(148, 163, 184, 0.1);
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
border-radius: 0.5rem;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.download-link:hover {
|
||||
background: rgba(99, 102, 241, 0.2);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.download-link svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.download-size {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.release-notes-link {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.release-notes-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
text-align: center;
|
||||
padding: 4rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--accent);
|
||||
background: rgba(236, 72, 153, 0.1);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.error-message a {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.no-releases {
|
||||
text-align: center;
|
||||
padding: 4rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
footer {
|
||||
padding: 3rem 0;
|
||||
text-align: center;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.1);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
footer a {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.page-header h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.release-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.release-downloads {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.download-link {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<nav class="container">
|
||||
<a href="index.html" class="logo">Automaker</a>
|
||||
<ul class="nav-links">
|
||||
<li><a href="index.html#features">Features</a></li>
|
||||
<li><a href="index.html#tech">Tech Stack</a></li>
|
||||
<li><a href="releases.html" class="active">Releases</a></li>
|
||||
<li><a href="https://github.com/AutoMaker-Org/automaker" target="_blank">GitHub</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="page-header">
|
||||
<div class="container">
|
||||
<h1>Releases</h1>
|
||||
<p>Download Automaker for your platform. All versions are available below.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="releases-section">
|
||||
<div class="container">
|
||||
<div class="releases-list" id="releasesList">
|
||||
<div class="loading-spinner">Loading releases...</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="container">
|
||||
<p>Made with love by <a href="mailto:webdevcody@gmail.com">Cody Seibert</a></p>
|
||||
<p style="margin-top: 1rem;">
|
||||
<a href="https://github.com/AutoMaker-Org/automaker" target="_blank">GitHub</a> |
|
||||
<a href="https://github.com/AutoMaker-Org/automaker/blob/main/LICENSE" target="_blank">License</a>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const R2_RELEASES_URL = window.RELEASES_JSON_URL || 'https://releases.automaker.app/releases.json';
|
||||
|
||||
const platformIcons = {
|
||||
windows: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M0 3.449L9.75 2.1v9.451H0m10.949-9.602L24 0v11.4H10.949M0 12.6h9.75v9.451L0 20.699M10.949 12.6H24V24l-12.9-1.801"/></svg>',
|
||||
macos: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z"/></svg>',
|
||||
macosArm: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z"/></svg>',
|
||||
linux: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12.504 0c-.155 0-.315.008-.48.021-4.226.333-3.105 4.807-3.17 6.298-.076 1.092-.3 1.953-1.05 3.02-.885 1.051-2.127 2.75-2.716 4.521-.278.832-.41 1.684-.287 2.489a.424.424 0 00-.11.135c-.26.268-.45.6-.663.839-.199.199-.485.267-.797.4-.313.136-.658.269-.864.68-.09.189-.136.394-.132.602 0 .199.027.4.055.536.058.399.116.728.04.97-.249.68-.28 1.145-.106 1.484.174.334.535.47.94.601.81.2 1.91.135 2.774.6.926.466 1.866.67 2.616.47.526-.116.97-.464 1.208-.946.587-.003 1.23-.269 2.26-.334.699-.058 1.574.267 2.577.2.025.134.063.198.114.333l.003.003c.391.778 1.113 1.132 1.884 1.071.771-.06 1.592-.536 2.257-1.306.631-.765 1.683-1.084 2.378-1.503.348-.199.629-.469.649-.853.023-.4-.2-.811-.714-1.376v-.097l-.003-.003c-.17-.2-.25-.535-.338-.926-.085-.401-.182-.786-.492-1.046h-.003c-.059-.054-.123-.067-.188-.135a.357.357 0 00-.19-.064c.431-1.278.264-2.55-.173-3.694-.533-1.41-1.465-2.638-2.175-3.483-.796-1.005-1.576-1.957-1.56-3.368.026-2.152.236-6.133-3.544-6.139zm.529 3.405h.013c.213 0 .396.062.584.198.19.135.33.332.438.533.105.259.158.459.166.724 0-.02.006-.04.006-.06v.105a.086.086 0 01-.004-.021l-.004-.024a1.807 1.807 0 01-.15.706.953.953 0 01-.213.335.71.71 0 00-.088-.042c-.104-.045-.198-.064-.284-.133a1.312 1.312 0 00-.22-.066c.05-.06.146-.133.183-.198.053-.128.082-.264.088-.402v-.02a1.21 1.21 0 00-.061-.4c-.045-.134-.101-.2-.183-.333-.084-.066-.167-.132-.267-.132h-.016c-.093 0-.176.03-.262.132a.8.8 0 00-.205.334 1.18 1.18 0 00-.09.4v.019c.002.089.008.179.02.267-.193-.067-.438-.135-.607-.202a1.635 1.635 0 01-.018-.2v-.02a1.772 1.772 0 01.15-.768c.082-.22.232-.406.43-.533a.985.985 0 01.594-.2zm-2.962.059h.036c.142 0 .27.048.399.135.146.129.264.288.344.465.09.199.14.4.153.667v.004c.007.134.006.2-.002.266v.08c-.03.007-.056.018-.083.024-.152.055-.274.135-.393.2.012-.09.013-.18.003-.267v-.015c-.012-.133-.04-.2-.082-.333a.613.613 0 00-.166-.267.248.248 0 00-.183-.064h-.021c-.071.006-.13.04-.186.132a.552.552 0 00-.12.27.944.944 0 00-.023.33v.015c.012.135.037.2.08.334.046.134.098.2.166.268.01.009.02.018.034.024-.07.057-.117.07-.176.136a.304.304 0 01-.131.068 2.62 2.62 0 01-.275-.402 1.772 1.772 0 01-.155-.667 1.759 1.759 0 01.08-.668 1.43 1.43 0 01.283-.535c.128-.133.26-.2.418-.2zm1.37 1.706c.332 0 .733.065 1.216.399.293.2.523.269 1.052.468h.003c.255.136.405.266.478.399v-.131a.571.571 0 01.016.47c-.123.31-.516.643-1.063.842v.002c-.268.135-.501.333-.775.465-.276.135-.588.292-1.012.267a1.139 1.139 0 01-.448-.067 3.566 3.566 0 01-.322-.198c-.195-.135-.363-.332-.612-.465v-.005h-.005c-.4-.246-.616-.512-.686-.71-.07-.268-.005-.47.193-.6.224-.135.38-.271.483-.336.104-.074.143-.102.176-.131h.002v-.003c.169-.202.436-.47.839-.601.139-.036.294-.065.466-.065zm2.8 2.142c.358 1.417 1.196 3.475 1.735 4.473.286.534.855 1.659 1.102 3.024.156-.005.33.018.513.064.646-1.671-.546-3.467-1.089-3.966-.22-.2-.232-.335-.123-.335.59.534 1.365 1.572 1.646 2.757.13.535.16 1.104.021 1.67.067.028.135.06.205.067 1.032.534 1.413.938 1.23 1.537v-.002c-.06-.003-.12 0-.18 0h-.016c.151-.467-.182-.825-1.065-1.224-.915-.4-1.646-.336-1.77.465-.008.043-.013.066-.018.135-.068.023-.139.053-.209.064-.43.268-.662.669-.793 1.187-.13.533-.17 1.156-.205 1.869v.003c-.02.482-.04 1.053-.158 1.425-.06.134-.133.27-.238.465h-.003c-.067-.004-.003-.401-.004-.469.006-.534.011-1.2.036-1.534.006-.468.011-.534-.021-.267-.18.936-.323 1.2-.608 1.67a1.016 1.016 0 01-.112.134v.003l-.005-.003c-.07-.2-.044-.401-.044-.535-.002-.468.006-.869-.089-1.334-.066-.468-.353-.935-.711-1.469-.074-.104-.264-.333-.376-.533-.073-.133-.067-.267.123-.336.104-.037.2-.135.29-.2.09-.067.18-.136.27-.2.02-.015.04-.018.059-.036.14-.083.267-.2.368-.335a.838.838 0 00.145-.262l.002-.004c.028-.087.042-.133.034-.2-.034-.135-.232-.333-.393-.468-.226-.2-.4-.333-.673-.467l-.005-.002c-.569-.27-1.322-.534-1.927-.8a.082.082 0 01-.026-.013c-.136-.071-.27-.2-.406-.4-.466-.735-.727-1.536-.727-1.936 0-.2.067-.4.129-.533.032-.067.065-.135.102-.2.036-.067.257-.2.378-.267.143-.095.287-.191.441-.263z"/></svg>'
|
||||
};
|
||||
|
||||
const platformLabels = {
|
||||
windows: 'Windows',
|
||||
macos: 'macOS (Intel)',
|
||||
macosArm: 'macOS (Apple Silicon)',
|
||||
linux: 'Linux'
|
||||
};
|
||||
|
||||
function formatDate(isoString) {
|
||||
const date = new Date(isoString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
const mb = bytes / (1024 * 1024);
|
||||
return mb.toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
function renderRelease(release, isLatest) {
|
||||
const assets = Object.entries(release.assets)
|
||||
.filter(([_, asset]) => asset)
|
||||
.map(([platform, asset]) => `
|
||||
<a href="${asset.url}" class="download-link">
|
||||
${platformIcons[platform] || ''}
|
||||
<span>${platformLabels[platform] || platform}</span>
|
||||
<span class="download-size">${formatSize(asset.size)}</span>
|
||||
</a>
|
||||
`).join('');
|
||||
|
||||
return `
|
||||
<div class="release-card">
|
||||
<div class="release-header">
|
||||
<div>
|
||||
<span class="release-version">${release.version}</span>
|
||||
${isLatest ? '<span class="latest-badge">Latest</span>' : ''}
|
||||
</div>
|
||||
<span class="release-date">${formatDate(release.date)}</span>
|
||||
</div>
|
||||
<div class="release-downloads">
|
||||
${assets}
|
||||
</div>
|
||||
<a href="${release.githubReleaseUrl}" class="release-notes-link" target="_blank">
|
||||
View release notes on GitHub
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
|
||||
<polyline points="15 3 21 3 21 9"></polyline>
|
||||
<line x1="10" y1="14" x2="21" y2="3"></line>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function loadReleases() {
|
||||
const container = document.getElementById('releasesList');
|
||||
|
||||
try {
|
||||
const response = await fetch(R2_RELEASES_URL);
|
||||
if (!response.ok) throw new Error('Failed to fetch releases');
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.releases || data.releases.length === 0) {
|
||||
container.innerHTML = '<div class="no-releases">No releases available yet. Check back soon!</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = data.releases
|
||||
.map((release, index) => renderRelease(release, index === 0))
|
||||
.join('');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load releases:', error);
|
||||
container.innerHTML = `
|
||||
<div class="error-message">
|
||||
<p>Unable to load releases. Please try again later or visit our
|
||||
<a href="https://github.com/AutoMaker-Org/automaker/releases" target="_blank">GitHub releases page</a>.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
loadReleases();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -38,9 +38,6 @@ DATA_DIR=./data
|
||||
# OPTIONAL - Additional AI Providers
|
||||
# ============================================
|
||||
|
||||
# OpenAI API key (for Codex CLI support)
|
||||
OPENAI_API_KEY=
|
||||
|
||||
# Google API key (for future Gemini support)
|
||||
GOOGLE_API_KEY=
|
||||
|
||||
@@ -54,3 +51,5 @@ TERMINAL_ENABLED=true
|
||||
# Password to protect terminal access (leave empty for no password)
|
||||
# If set, users must enter this password before accessing terminal
|
||||
TERMINAL_PASSWORD=
|
||||
|
||||
ENABLE_REQUEST_LOGGING=false
|
||||
|
||||
4
apps/server/.gitignore
vendored
4
apps/server/.gitignore
vendored
@@ -1,2 +1,4 @@
|
||||
.env
|
||||
data
|
||||
data
|
||||
node_modules
|
||||
coverage
|
||||
@@ -9,22 +9,33 @@
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"lint": "eslint src/"
|
||||
"lint": "eslint src/",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:run": "vitest run",
|
||||
"test:cov": "vitest run --coverage",
|
||||
"test:watch": "vitest watch",
|
||||
"test:unit": "vitest run tests/unit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.1.61",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.1.72",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^5.1.0",
|
||||
"express": "^5.2.1",
|
||||
"morgan": "^1.10.1",
|
||||
"node-pty": "1.1.0-beta41",
|
||||
"ws": "^8.18.0"
|
||||
"ws": "^8.18.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.18",
|
||||
"@types/express": "^5.0.1",
|
||||
"@types/node": "^20",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/morgan": "^1.9.10",
|
||||
"@types/node": "^22",
|
||||
"@types/ws": "^8.18.1",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5"
|
||||
"@vitest/coverage-v8": "^4.0.16",
|
||||
"@vitest/ui": "^4.0.16",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.0.16"
|
||||
}
|
||||
}
|
||||
|
||||
2267
apps/server/pnpm-lock.yaml
generated
Normal file
2267
apps/server/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@
|
||||
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import morgan from "morgan";
|
||||
import { WebSocketServer, WebSocket } from "ws";
|
||||
import { createServer } from "http";
|
||||
import dotenv from "dotenv";
|
||||
@@ -15,56 +16,56 @@ import dotenv from "dotenv";
|
||||
import { createEventEmitter, type EventEmitter } from "./lib/events.js";
|
||||
import { initAllowedPaths } from "./lib/security.js";
|
||||
import { authMiddleware, getAuthStatus } from "./lib/auth.js";
|
||||
import { createFsRoutes } from "./routes/fs.js";
|
||||
import { createHealthRoutes } from "./routes/health.js";
|
||||
import { createAgentRoutes } from "./routes/agent.js";
|
||||
import { createSessionsRoutes } from "./routes/sessions.js";
|
||||
import { createFeaturesRoutes } from "./routes/features.js";
|
||||
import { createAutoModeRoutes } from "./routes/auto-mode.js";
|
||||
import { createWorktreeRoutes } from "./routes/worktree.js";
|
||||
import { createGitRoutes } from "./routes/git.js";
|
||||
import { createSetupRoutes } from "./routes/setup.js";
|
||||
import { createSuggestionsRoutes } from "./routes/suggestions.js";
|
||||
import { createModelsRoutes } from "./routes/models.js";
|
||||
import { createSpecRegenerationRoutes } from "./routes/spec-regeneration.js";
|
||||
import { createRunningAgentsRoutes } from "./routes/running-agents.js";
|
||||
import { createWorkspaceRoutes } from "./routes/workspace.js";
|
||||
import { createTemplatesRoutes } from "./routes/templates.js";
|
||||
import { createTerminalRoutes, validateTerminalToken, isTerminalEnabled, isTerminalPasswordRequired } from "./routes/terminal.js";
|
||||
import { createFsRoutes } from "./routes/fs/index.js";
|
||||
import { createHealthRoutes } from "./routes/health/index.js";
|
||||
import { createAgentRoutes } from "./routes/agent/index.js";
|
||||
import { createSessionsRoutes } from "./routes/sessions/index.js";
|
||||
import { createFeaturesRoutes } from "./routes/features/index.js";
|
||||
import { createAutoModeRoutes } from "./routes/auto-mode/index.js";
|
||||
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";
|
||||
import { createTemplatesRoutes } from "./routes/templates/index.js";
|
||||
import {
|
||||
createTerminalRoutes,
|
||||
validateTerminalToken,
|
||||
isTerminalEnabled,
|
||||
isTerminalPasswordRequired,
|
||||
} from "./routes/terminal/index.js";
|
||||
import { AgentService } from "./services/agent-service.js";
|
||||
import { FeatureLoader } from "./services/feature-loader.js";
|
||||
import { AutoModeService } from "./services/auto-mode-service.js";
|
||||
import { getTerminalService } from "./services/terminal-service.js";
|
||||
import { createSpecRegenerationRoutes } from "./routes/app-spec/index.js";
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
const PORT = parseInt(process.env.PORT || "3008", 10);
|
||||
const DATA_DIR = process.env.DATA_DIR || "./data";
|
||||
const ENABLE_REQUEST_LOGGING = process.env.ENABLE_REQUEST_LOGGING !== "false"; // Default to true
|
||||
|
||||
// Check for required environment variables
|
||||
// Claude Agent SDK supports EITHER OAuth token (subscription) OR API key (pay-per-use)
|
||||
const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
|
||||
const hasOAuthToken = !!process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
|
||||
if (!hasAnthropicKey && !hasOAuthToken) {
|
||||
if (!hasAnthropicKey) {
|
||||
console.warn(`
|
||||
╔═══════════════════════════════════════════════════════════════════════╗
|
||||
║ ⚠️ WARNING: No Claude authentication configured ║
|
||||
║ ║
|
||||
║ The Claude Agent SDK requires authentication to function. ║
|
||||
║ ║
|
||||
║ Option 1 - Subscription (OAuth Token): ║
|
||||
║ export CLAUDE_CODE_OAUTH_TOKEN="your-oauth-token" ║
|
||||
║ ║
|
||||
║ Option 2 - Pay-per-use (API Key): ║
|
||||
║ Set your Anthropic API key: ║
|
||||
║ export ANTHROPIC_API_KEY="sk-ant-..." ║
|
||||
║ ║
|
||||
║ Or use the setup wizard in Settings to configure authentication. ║
|
||||
╚═══════════════════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
} else if (hasOAuthToken) {
|
||||
console.log("[Server] ✓ CLAUDE_CODE_OAUTH_TOKEN detected (subscription auth)");
|
||||
} else {
|
||||
console.log("[Server] ✓ ANTHROPIC_API_KEY detected (API key auth)");
|
||||
}
|
||||
@@ -76,6 +77,22 @@ 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
|
||||
});
|
||||
|
||||
app.use(
|
||||
morgan(":method :url :status-colored", {
|
||||
skip: (req) => req.url === "/api/health", // Skip health check logs
|
||||
})
|
||||
);
|
||||
}
|
||||
app.use(
|
||||
cors({
|
||||
origin: process.env.CORS_ORIGIN || "*",
|
||||
@@ -109,6 +126,7 @@ app.use("/api/agent", createAgentRoutes(agentService, events));
|
||||
app.use("/api/sessions", createSessionsRoutes(agentService));
|
||||
app.use("/api/features", createFeaturesRoutes(featureLoader));
|
||||
app.use("/api/auto-mode", createAutoModeRoutes(autoModeService));
|
||||
app.use("/api/enhance-prompt", createEnhancePromptRoutes());
|
||||
app.use("/api/worktree", createWorktreeRoutes());
|
||||
app.use("/api/git", createGitRoutes());
|
||||
app.use("/api/setup", createSetupRoutes());
|
||||
@@ -130,7 +148,10 @@ const terminalService = getTerminalService();
|
||||
|
||||
// Handle HTTP upgrade requests manually to route to correct WebSocket server
|
||||
server.on("upgrade", (request, socket, head) => {
|
||||
const { pathname } = new URL(request.url || "", `http://${request.headers.host}`);
|
||||
const { pathname } = new URL(
|
||||
request.url || "",
|
||||
`http://${request.headers.host}`
|
||||
);
|
||||
|
||||
if (pathname === "/api/events") {
|
||||
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
@@ -169,154 +190,241 @@ wss.on("connection", (ws: WebSocket) => {
|
||||
|
||||
// Track WebSocket connections per session
|
||||
const terminalConnections: Map<string, Set<WebSocket>> = new Map();
|
||||
// Track last resize dimensions per session to deduplicate resize messages
|
||||
const lastResizeDimensions: Map<string, { cols: number; rows: number }> = new Map();
|
||||
// Track last resize timestamp to rate-limit resize operations (prevents resize storm)
|
||||
const lastResizeTime: Map<string, number> = new Map();
|
||||
const RESIZE_MIN_INTERVAL_MS = 100; // Minimum 100ms between resize operations
|
||||
|
||||
// Terminal WebSocket connection handler
|
||||
terminalWss.on("connection", (ws: WebSocket, req: import("http").IncomingMessage) => {
|
||||
// Parse URL to get session ID and token
|
||||
const url = new URL(req.url || "", `http://${req.headers.host}`);
|
||||
const sessionId = url.searchParams.get("sessionId");
|
||||
const token = url.searchParams.get("token");
|
||||
|
||||
console.log(`[Terminal WS] Connection attempt for session: ${sessionId}`);
|
||||
|
||||
// Check if terminal is enabled
|
||||
if (!isTerminalEnabled()) {
|
||||
console.log("[Terminal WS] Terminal is disabled");
|
||||
ws.close(4003, "Terminal access is disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate token if password is required
|
||||
if (isTerminalPasswordRequired() && !validateTerminalToken(token || undefined)) {
|
||||
console.log("[Terminal WS] Invalid or missing token");
|
||||
ws.close(4001, "Authentication required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
console.log("[Terminal WS] No session ID provided");
|
||||
ws.close(4002, "Session ID required");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if session exists
|
||||
const session = terminalService.getSession(sessionId);
|
||||
if (!session) {
|
||||
console.log(`[Terminal WS] Session ${sessionId} not found`);
|
||||
ws.close(4004, "Session not found");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[Terminal WS] Client connected to session ${sessionId}`);
|
||||
|
||||
// Track this connection
|
||||
if (!terminalConnections.has(sessionId)) {
|
||||
terminalConnections.set(sessionId, new Set());
|
||||
}
|
||||
terminalConnections.get(sessionId)!.add(ws);
|
||||
|
||||
// Subscribe to terminal data
|
||||
const unsubscribeData = terminalService.onData((sid, data) => {
|
||||
if (sid === sessionId && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "data", data }));
|
||||
}
|
||||
});
|
||||
|
||||
// Subscribe to terminal exit
|
||||
const unsubscribeExit = terminalService.onExit((sid, exitCode) => {
|
||||
if (sid === sessionId && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "exit", exitCode }));
|
||||
ws.close(1000, "Session ended");
|
||||
}
|
||||
});
|
||||
|
||||
// Handle incoming messages
|
||||
ws.on("message", (message) => {
|
||||
try {
|
||||
const msg = JSON.parse(message.toString());
|
||||
|
||||
switch (msg.type) {
|
||||
case "input":
|
||||
// Write user input to terminal
|
||||
terminalService.write(sessionId, msg.data);
|
||||
break;
|
||||
|
||||
case "resize":
|
||||
// Resize terminal
|
||||
if (msg.cols && msg.rows) {
|
||||
terminalService.resize(sessionId, msg.cols, msg.rows);
|
||||
}
|
||||
break;
|
||||
|
||||
case "ping":
|
||||
// Respond to ping
|
||||
ws.send(JSON.stringify({ type: "pong" }));
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn(`[Terminal WS] Unknown message type: ${msg.type}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Terminal WS] Error processing message:", error);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
console.log(`[Terminal WS] Client disconnected from session ${sessionId}`);
|
||||
unsubscribeData();
|
||||
unsubscribeExit();
|
||||
|
||||
// Remove from connections tracking
|
||||
const connections = terminalConnections.get(sessionId);
|
||||
if (connections) {
|
||||
connections.delete(ws);
|
||||
if (connections.size === 0) {
|
||||
terminalConnections.delete(sessionId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("error", (error) => {
|
||||
console.error(`[Terminal WS] Error on session ${sessionId}:`, error);
|
||||
unsubscribeData();
|
||||
unsubscribeExit();
|
||||
});
|
||||
|
||||
// Send initial connection success
|
||||
ws.send(JSON.stringify({
|
||||
type: "connected",
|
||||
sessionId,
|
||||
shell: session.shell,
|
||||
cwd: session.cwd,
|
||||
}));
|
||||
|
||||
// Send scrollback buffer to replay previous output
|
||||
const scrollback = terminalService.getScrollback(sessionId);
|
||||
if (scrollback && scrollback.length > 0) {
|
||||
ws.send(JSON.stringify({
|
||||
type: "scrollback",
|
||||
data: scrollback,
|
||||
}));
|
||||
}
|
||||
// Clean up resize tracking when sessions actually exit (not just when connections close)
|
||||
terminalService.onExit((sessionId) => {
|
||||
lastResizeDimensions.delete(sessionId);
|
||||
lastResizeTime.delete(sessionId);
|
||||
terminalConnections.delete(sessionId);
|
||||
});
|
||||
|
||||
// Start server
|
||||
server.listen(PORT, () => {
|
||||
const terminalStatus = isTerminalEnabled()
|
||||
? (isTerminalPasswordRequired() ? "enabled (password protected)" : "enabled")
|
||||
: "disabled";
|
||||
console.log(`
|
||||
// Terminal WebSocket connection handler
|
||||
terminalWss.on(
|
||||
"connection",
|
||||
(ws: WebSocket, req: import("http").IncomingMessage) => {
|
||||
// Parse URL to get session ID and token
|
||||
const url = new URL(req.url || "", `http://${req.headers.host}`);
|
||||
const sessionId = url.searchParams.get("sessionId");
|
||||
const token = url.searchParams.get("token");
|
||||
|
||||
console.log(`[Terminal WS] Connection attempt for session: ${sessionId}`);
|
||||
|
||||
// Check if terminal is enabled
|
||||
if (!isTerminalEnabled()) {
|
||||
console.log("[Terminal WS] Terminal is disabled");
|
||||
ws.close(4003, "Terminal access is disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate token if password is required
|
||||
if (
|
||||
isTerminalPasswordRequired() &&
|
||||
!validateTerminalToken(token || undefined)
|
||||
) {
|
||||
console.log("[Terminal WS] Invalid or missing token");
|
||||
ws.close(4001, "Authentication required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
console.log("[Terminal WS] No session ID provided");
|
||||
ws.close(4002, "Session ID required");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if session exists
|
||||
const session = terminalService.getSession(sessionId);
|
||||
if (!session) {
|
||||
console.log(`[Terminal WS] Session ${sessionId} not found`);
|
||||
ws.close(4004, "Session not found");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[Terminal WS] Client connected to session ${sessionId}`);
|
||||
|
||||
// Track this connection
|
||||
if (!terminalConnections.has(sessionId)) {
|
||||
terminalConnections.set(sessionId, new Set());
|
||||
}
|
||||
terminalConnections.get(sessionId)!.add(ws);
|
||||
|
||||
// Send initial connection success FIRST
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "connected",
|
||||
sessionId,
|
||||
shell: session.shell,
|
||||
cwd: session.cwd,
|
||||
})
|
||||
);
|
||||
|
||||
// Send scrollback buffer BEFORE subscribing to prevent race condition
|
||||
// Also clear pending output buffer to prevent duplicates from throttled flush
|
||||
const scrollback = terminalService.getScrollbackAndClearPending(sessionId);
|
||||
if (scrollback && scrollback.length > 0) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "scrollback",
|
||||
data: scrollback,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// NOW subscribe to terminal data (after scrollback is sent)
|
||||
const unsubscribeData = terminalService.onData((sid, data) => {
|
||||
if (sid === sessionId && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "data", data }));
|
||||
}
|
||||
});
|
||||
|
||||
// Subscribe to terminal exit
|
||||
const unsubscribeExit = terminalService.onExit((sid, exitCode) => {
|
||||
if (sid === sessionId && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "exit", exitCode }));
|
||||
ws.close(1000, "Session ended");
|
||||
}
|
||||
});
|
||||
|
||||
// Handle incoming messages
|
||||
ws.on("message", (message) => {
|
||||
try {
|
||||
const msg = JSON.parse(message.toString());
|
||||
|
||||
switch (msg.type) {
|
||||
case "input":
|
||||
// Write user input to terminal
|
||||
terminalService.write(sessionId, msg.data);
|
||||
break;
|
||||
|
||||
case "resize":
|
||||
// Resize terminal with deduplication and rate limiting
|
||||
if (msg.cols && msg.rows) {
|
||||
const now = Date.now();
|
||||
const lastTime = lastResizeTime.get(sessionId) || 0;
|
||||
const lastDimensions = lastResizeDimensions.get(sessionId);
|
||||
|
||||
// Skip if resized too recently (prevents resize storm during splits)
|
||||
if (now - lastTime < RESIZE_MIN_INTERVAL_MS) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if dimensions are different from last resize
|
||||
if (
|
||||
!lastDimensions ||
|
||||
lastDimensions.cols !== msg.cols ||
|
||||
lastDimensions.rows !== msg.rows
|
||||
) {
|
||||
// Only suppress output on subsequent resizes, not the first one
|
||||
// The first resize happens on terminal open and we don't want to drop the initial prompt
|
||||
const isFirstResize = !lastDimensions;
|
||||
terminalService.resize(sessionId, msg.cols, msg.rows, !isFirstResize);
|
||||
lastResizeDimensions.set(sessionId, {
|
||||
cols: msg.cols,
|
||||
rows: msg.rows,
|
||||
});
|
||||
lastResizeTime.set(sessionId, now);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "ping":
|
||||
// Respond to ping
|
||||
ws.send(JSON.stringify({ type: "pong" }));
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn(`[Terminal WS] Unknown message type: ${msg.type}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Terminal WS] Error processing message:", error);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
console.log(
|
||||
`[Terminal WS] Client disconnected from session ${sessionId}`
|
||||
);
|
||||
unsubscribeData();
|
||||
unsubscribeExit();
|
||||
|
||||
// Remove from connections tracking
|
||||
const connections = terminalConnections.get(sessionId);
|
||||
if (connections) {
|
||||
connections.delete(ws);
|
||||
if (connections.size === 0) {
|
||||
terminalConnections.delete(sessionId);
|
||||
// DON'T delete lastResizeDimensions/lastResizeTime here!
|
||||
// The session still exists, and reconnecting clients need to know
|
||||
// this isn't the "first resize" to prevent duplicate prompts.
|
||||
// These get cleaned up when the session actually exits.
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("error", (error) => {
|
||||
console.error(`[Terminal WS] Error on session ${sessionId}:`, error);
|
||||
unsubscribeData();
|
||||
unsubscribeExit();
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Start server with error handling for port conflicts
|
||||
const startServer = (port: number) => {
|
||||
server.listen(port, () => {
|
||||
const terminalStatus = isTerminalEnabled()
|
||||
? isTerminalPasswordRequired()
|
||||
? "enabled (password protected)"
|
||||
: "enabled"
|
||||
: "disabled";
|
||||
const portStr = port.toString().padEnd(4);
|
||||
console.log(`
|
||||
╔═══════════════════════════════════════════════════════╗
|
||||
║ Automaker Backend Server ║
|
||||
╠═══════════════════════════════════════════════════════╣
|
||||
║ HTTP API: http://localhost:${PORT} ║
|
||||
║ WebSocket: ws://localhost:${PORT}/api/events ║
|
||||
║ Terminal: ws://localhost:${PORT}/api/terminal/ws ║
|
||||
║ Health: http://localhost:${PORT}/api/health ║
|
||||
║ 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)}║
|
||||
╚═══════════════════════════════════════════════════════╝
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
server.on("error", (error: NodeJS.ErrnoException) => {
|
||||
if (error.code === "EADDRINUSE") {
|
||||
console.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 ║
|
||||
╚═══════════════════════════════════════════════════════╝
|
||||
`);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.error("[Server] Error starting server:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
startServer(PORT);
|
||||
|
||||
// Graceful shutdown
|
||||
process.on("SIGTERM", () => {
|
||||
|
||||
318
apps/server/src/lib/app-spec-format.ts
Normal file
318
apps/server/src/lib/app-spec-format.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* XML Template Format Specification for app_spec.txt
|
||||
*
|
||||
* This format must be included in all prompts that generate, modify, or regenerate
|
||||
* app specifications to ensure consistency across the application.
|
||||
*/
|
||||
|
||||
/**
|
||||
* TypeScript interface for structured spec output
|
||||
*/
|
||||
export interface SpecOutput {
|
||||
project_name: string;
|
||||
overview: string;
|
||||
technology_stack: string[];
|
||||
core_capabilities: string[];
|
||||
implemented_features: Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
file_locations?: string[];
|
||||
}>;
|
||||
additional_requirements?: string[];
|
||||
development_guidelines?: string[];
|
||||
implementation_roadmap?: Array<{
|
||||
phase: string;
|
||||
status: "completed" | "in_progress" | "pending";
|
||||
description: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON Schema for structured spec output
|
||||
* Used with Claude's structured output feature for reliable parsing
|
||||
*/
|
||||
export const specOutputSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
project_name: {
|
||||
type: "string",
|
||||
description: "The name of the project",
|
||||
},
|
||||
overview: {
|
||||
type: "string",
|
||||
description:
|
||||
"A comprehensive description of what the project does, its purpose, and key goals",
|
||||
},
|
||||
technology_stack: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description:
|
||||
"List of all technologies, frameworks, libraries, and tools used",
|
||||
},
|
||||
core_capabilities: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "List of main features and capabilities the project provides",
|
||||
},
|
||||
implemented_features: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: {
|
||||
type: "string",
|
||||
description: "Name of the implemented feature",
|
||||
},
|
||||
description: {
|
||||
type: "string",
|
||||
description: "Description of what the feature does",
|
||||
},
|
||||
file_locations: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "File paths where this feature is implemented",
|
||||
},
|
||||
},
|
||||
required: ["name", "description"],
|
||||
},
|
||||
description: "Features that have been implemented based on code analysis",
|
||||
},
|
||||
additional_requirements: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Any additional requirements or constraints",
|
||||
},
|
||||
development_guidelines: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Development standards and practices",
|
||||
},
|
||||
implementation_roadmap: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
phase: {
|
||||
type: "string",
|
||||
description: "Name of the implementation phase",
|
||||
},
|
||||
status: {
|
||||
type: "string",
|
||||
enum: ["completed", "in_progress", "pending"],
|
||||
description: "Current status of this phase",
|
||||
},
|
||||
description: {
|
||||
type: "string",
|
||||
description: "Description of what this phase involves",
|
||||
},
|
||||
},
|
||||
required: ["phase", "status", "description"],
|
||||
},
|
||||
description: "Phases or roadmap items for implementation",
|
||||
},
|
||||
},
|
||||
required: [
|
||||
"project_name",
|
||||
"overview",
|
||||
"technology_stack",
|
||||
"core_capabilities",
|
||||
"implemented_features",
|
||||
],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Escape special XML characters
|
||||
*/
|
||||
function escapeXml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert structured spec output to XML format
|
||||
*/
|
||||
export function specToXml(spec: SpecOutput): string {
|
||||
const indent = " ";
|
||||
|
||||
let xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project_specification>
|
||||
${indent}<project_name>${escapeXml(spec.project_name)}</project_name>
|
||||
|
||||
${indent}<overview>
|
||||
${indent}${indent}${escapeXml(spec.overview)}
|
||||
${indent}</overview>
|
||||
|
||||
${indent}<technology_stack>
|
||||
${spec.technology_stack.map((t) => `${indent}${indent}<technology>${escapeXml(t)}</technology>`).join("\n")}
|
||||
${indent}</technology_stack>
|
||||
|
||||
${indent}<core_capabilities>
|
||||
${spec.core_capabilities.map((c) => `${indent}${indent}<capability>${escapeXml(c)}</capability>`).join("\n")}
|
||||
${indent}</core_capabilities>
|
||||
|
||||
${indent}<implemented_features>
|
||||
${spec.implemented_features
|
||||
.map(
|
||||
(f) => `${indent}${indent}<feature>
|
||||
${indent}${indent}${indent}<name>${escapeXml(f.name)}</name>
|
||||
${indent}${indent}${indent}<description>${escapeXml(f.description)}</description>${
|
||||
f.file_locations && f.file_locations.length > 0
|
||||
? `\n${indent}${indent}${indent}<file_locations>
|
||||
${f.file_locations.map((loc) => `${indent}${indent}${indent}${indent}<location>${escapeXml(loc)}</location>`).join("\n")}
|
||||
${indent}${indent}${indent}</file_locations>`
|
||||
: ""
|
||||
}
|
||||
${indent}${indent}</feature>`
|
||||
)
|
||||
.join("\n")}
|
||||
${indent}</implemented_features>`;
|
||||
|
||||
// Optional sections
|
||||
if (spec.additional_requirements && spec.additional_requirements.length > 0) {
|
||||
xml += `
|
||||
|
||||
${indent}<additional_requirements>
|
||||
${spec.additional_requirements.map((r) => `${indent}${indent}<requirement>${escapeXml(r)}</requirement>`).join("\n")}
|
||||
${indent}</additional_requirements>`;
|
||||
}
|
||||
|
||||
if (spec.development_guidelines && spec.development_guidelines.length > 0) {
|
||||
xml += `
|
||||
|
||||
${indent}<development_guidelines>
|
||||
${spec.development_guidelines.map((g) => `${indent}${indent}<guideline>${escapeXml(g)}</guideline>`).join("\n")}
|
||||
${indent}</development_guidelines>`;
|
||||
}
|
||||
|
||||
if (spec.implementation_roadmap && spec.implementation_roadmap.length > 0) {
|
||||
xml += `
|
||||
|
||||
${indent}<implementation_roadmap>
|
||||
${spec.implementation_roadmap
|
||||
.map(
|
||||
(r) => `${indent}${indent}<phase>
|
||||
${indent}${indent}${indent}<name>${escapeXml(r.phase)}</name>
|
||||
${indent}${indent}${indent}<status>${escapeXml(r.status)}</status>
|
||||
${indent}${indent}${indent}<description>${escapeXml(r.description)}</description>
|
||||
${indent}${indent}</phase>`
|
||||
)
|
||||
.join("\n")}
|
||||
${indent}</implementation_roadmap>`;
|
||||
}
|
||||
|
||||
xml += `
|
||||
</project_specification>`;
|
||||
|
||||
return xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get prompt instruction for structured output (simpler than XML instructions)
|
||||
*/
|
||||
export function getStructuredSpecPromptInstruction(): string {
|
||||
return `
|
||||
Analyze the project and provide a comprehensive specification with:
|
||||
|
||||
1. **project_name**: The name of the project
|
||||
2. **overview**: A comprehensive description of what the project does, its purpose, and key goals
|
||||
3. **technology_stack**: List all technologies, frameworks, libraries, and tools used
|
||||
4. **core_capabilities**: List the main features and capabilities the project provides
|
||||
5. **implemented_features**: For each implemented feature, provide:
|
||||
- name: Feature name
|
||||
- description: What it does
|
||||
- file_locations: Key files where it's implemented (optional)
|
||||
6. **additional_requirements**: Any system requirements, dependencies, or constraints (optional)
|
||||
7. **development_guidelines**: Development standards and best practices (optional)
|
||||
8. **implementation_roadmap**: Project phases with status (completed/in_progress/pending) (optional)
|
||||
|
||||
Be thorough in your analysis. The output will be automatically formatted as structured JSON.
|
||||
`;
|
||||
}
|
||||
export const APP_SPEC_XML_FORMAT = `
|
||||
The app_spec.txt file MUST follow this exact XML format:
|
||||
|
||||
<project_specification>
|
||||
<project_name>Project Name</project_name>
|
||||
|
||||
<overview>
|
||||
A comprehensive description of what the project does, its purpose, and key goals.
|
||||
</overview>
|
||||
|
||||
<technology_stack>
|
||||
<technology>Technology 1</technology>
|
||||
<technology>Technology 2</technology>
|
||||
<!-- List all technologies, frameworks, libraries, and tools used -->
|
||||
</technology_stack>
|
||||
|
||||
<core_capabilities>
|
||||
<capability>Core capability 1</capability>
|
||||
<capability>Core capability 2</capability>
|
||||
<!-- List main features and capabilities the project provides -->
|
||||
</core_capabilities>
|
||||
|
||||
<implemented_features>
|
||||
<!-- Features that have been implemented (populated by AI agent based on code analysis) -->
|
||||
</implemented_features>
|
||||
|
||||
<!-- Optional sections that may be included: -->
|
||||
<additional_requirements>
|
||||
<!-- Any additional requirements or constraints -->
|
||||
</additional_requirements>
|
||||
|
||||
<development_guidelines>
|
||||
<guideline>Guideline 1</guideline>
|
||||
<guideline>Guideline 2</guideline>
|
||||
<!-- Development standards and practices -->
|
||||
</development_guidelines>
|
||||
|
||||
<implementation_roadmap>
|
||||
<!-- Phases or roadmap items for implementation -->
|
||||
</implementation_roadmap>
|
||||
</project_specification>
|
||||
|
||||
IMPORTANT:
|
||||
- All content must be wrapped in valid XML tags
|
||||
- Use proper XML escaping for special characters (<, >, &)
|
||||
- Maintain proper indentation (2 spaces)
|
||||
- All sections should be populated based on project analysis
|
||||
- The format must be strictly followed - do not use markdown, JSON, or any other format
|
||||
`;
|
||||
|
||||
/**
|
||||
* Returns a prompt suffix that instructs the AI to format the response as XML
|
||||
* following the app_spec.txt template format.
|
||||
*/
|
||||
export function getAppSpecFormatInstruction(): string {
|
||||
return `
|
||||
${APP_SPEC_XML_FORMAT}
|
||||
|
||||
CRITICAL FORMATTING REQUIREMENTS:
|
||||
- Do NOT use the Write, Edit, or Bash tools to create files - just OUTPUT the XML in your response
|
||||
- Your ENTIRE response MUST be valid XML following the exact template structure above
|
||||
- Do NOT use markdown formatting (no # headers, no **bold**, no - lists, etc.)
|
||||
- Do NOT include any explanatory text, prefix, or suffix outside the XML tags
|
||||
- Do NOT include phrases like "Based on my analysis...", "I'll create...", "Let me analyze..." before the XML
|
||||
- Do NOT include any text before <project_specification> or after </project_specification>
|
||||
- Your response must start IMMEDIATELY with <project_specification> with no preceding text
|
||||
- Your response must end IMMEDIATELY with </project_specification> with no following text
|
||||
- Use ONLY XML tags as shown in the template
|
||||
- Properly escape XML special characters (< for <, > for >, & for &)
|
||||
- Maintain 2-space indentation for readability
|
||||
- The output will be saved directly to app_spec.txt and must be parseable as valid XML
|
||||
- The response must contain exactly ONE root XML element: <project_specification>
|
||||
- Do not include code blocks, markdown fences, or any other formatting
|
||||
|
||||
VERIFICATION: Before responding, verify that:
|
||||
1. Your response starts with <project_specification> (no spaces, no text before it)
|
||||
2. Your response ends with </project_specification> (no spaces, no text after it)
|
||||
3. There is exactly one root XML element
|
||||
4. There is no explanatory text, analysis, or commentary outside the XML tags
|
||||
|
||||
Your response should be ONLY the XML content, nothing else.
|
||||
`;
|
||||
}
|
||||
91
apps/server/src/lib/automaker-paths.ts
Normal file
91
apps/server/src/lib/automaker-paths.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Automaker Paths - Utilities for managing automaker data storage
|
||||
*
|
||||
* Stores project data inside the project directory at {projectPath}/.automaker/
|
||||
*/
|
||||
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
/**
|
||||
* Get the automaker data directory for a project
|
||||
* This is stored inside the project at .automaker/
|
||||
*/
|
||||
export function getAutomakerDir(projectPath: string): string {
|
||||
return path.join(projectPath, ".automaker");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the features directory for a project
|
||||
*/
|
||||
export function getFeaturesDir(projectPath: string): string {
|
||||
return path.join(getAutomakerDir(projectPath), "features");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the directory for a specific feature
|
||||
*/
|
||||
export function getFeatureDir(projectPath: string, featureId: string): string {
|
||||
return path.join(getFeaturesDir(projectPath), featureId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the images directory for a feature
|
||||
*/
|
||||
export function getFeatureImagesDir(
|
||||
projectPath: string,
|
||||
featureId: string
|
||||
): string {
|
||||
return path.join(getFeatureDir(projectPath, featureId), "images");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the board directory for a project (board backgrounds, etc.)
|
||||
*/
|
||||
export function getBoardDir(projectPath: string): string {
|
||||
return path.join(getAutomakerDir(projectPath), "board");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the images directory for a project (general images)
|
||||
*/
|
||||
export function getImagesDir(projectPath: string): string {
|
||||
return path.join(getAutomakerDir(projectPath), "images");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the context files directory for a project (user-added context files)
|
||||
*/
|
||||
export function getContextDir(projectPath: string): string {
|
||||
return path.join(getAutomakerDir(projectPath), "context");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the worktrees metadata directory for a project
|
||||
*/
|
||||
export function getWorktreesDir(projectPath: string): string {
|
||||
return path.join(getAutomakerDir(projectPath), "worktrees");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the app spec file path for a project
|
||||
*/
|
||||
export function getAppSpecPath(projectPath: string): string {
|
||||
return path.join(getAutomakerDir(projectPath), "app_spec.txt");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the branch tracking file path for a project
|
||||
*/
|
||||
export function getBranchTrackingPath(projectPath: string): string {
|
||||
return path.join(getAutomakerDir(projectPath), "active-branches.json");
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the automaker directory structure exists for a project
|
||||
*/
|
||||
export async function ensureAutomakerDir(projectPath: string): Promise<string> {
|
||||
const automakerDir = getAutomakerDir(projectPath);
|
||||
await fs.mkdir(automakerDir, { recursive: true });
|
||||
return automakerDir;
|
||||
}
|
||||
97
apps/server/src/lib/conversation-utils.ts
Normal file
97
apps/server/src/lib/conversation-utils.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Conversation history utilities for processing message history
|
||||
*
|
||||
* Provides standardized conversation history handling:
|
||||
* - Extract text from content (string or array format)
|
||||
* - Normalize content blocks to array format
|
||||
* - Format history as plain text for CLI-based providers
|
||||
* - Convert history to Claude SDK message format
|
||||
*/
|
||||
|
||||
import type { ConversationMessage } from "../providers/types.js";
|
||||
|
||||
/**
|
||||
* Extract plain text from message content (handles both string and array formats)
|
||||
*
|
||||
* @param content - Message content (string or array of content blocks)
|
||||
* @returns Extracted text content
|
||||
*/
|
||||
export function extractTextFromContent(
|
||||
content: string | Array<{ type: string; text?: string; source?: object }>
|
||||
): string {
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
|
||||
// Extract text blocks only
|
||||
return content
|
||||
.filter((block) => block.type === "text")
|
||||
.map((block) => block.text || "")
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize message content to array format
|
||||
*
|
||||
* @param content - Message content (string or array)
|
||||
* @returns Content as array of blocks
|
||||
*/
|
||||
export function normalizeContentBlocks(
|
||||
content: string | Array<{ type: string; text?: string; source?: object }>
|
||||
): Array<{ type: string; text?: string; source?: object }> {
|
||||
if (Array.isArray(content)) {
|
||||
return content;
|
||||
}
|
||||
return [{ type: "text", text: content }];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format conversation history as plain text for CLI-based providers
|
||||
*
|
||||
* @param history - Array of conversation messages
|
||||
* @returns Formatted text with role labels
|
||||
*/
|
||||
export function formatHistoryAsText(history: ConversationMessage[]): string {
|
||||
if (history.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
let historyText = "Previous conversation:\n\n";
|
||||
|
||||
for (const msg of history) {
|
||||
const contentText = extractTextFromContent(msg.content);
|
||||
const role = msg.role === "user" ? "User" : "Assistant";
|
||||
historyText += `${role}: ${contentText}\n\n`;
|
||||
}
|
||||
|
||||
historyText += "---\n\n";
|
||||
return historyText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert conversation history to Claude SDK message format
|
||||
*
|
||||
* @param history - Array of conversation messages
|
||||
* @returns Array of Claude SDK formatted messages
|
||||
*/
|
||||
export function convertHistoryToMessages(
|
||||
history: ConversationMessage[]
|
||||
): Array<{
|
||||
type: "user" | "assistant";
|
||||
session_id: string;
|
||||
message: {
|
||||
role: "user" | "assistant";
|
||||
content: Array<{ type: string; text?: string; source?: object }>;
|
||||
};
|
||||
parent_tool_use_id: null;
|
||||
}> {
|
||||
return history.map((historyMsg) => ({
|
||||
type: historyMsg.role,
|
||||
session_id: "",
|
||||
message: {
|
||||
role: historyMsg.role,
|
||||
content: normalizeContentBlocks(historyMsg.content),
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
}));
|
||||
}
|
||||
221
apps/server/src/lib/dependency-resolver.ts
Normal file
221
apps/server/src/lib/dependency-resolver.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Dependency Resolution Utility (Server-side)
|
||||
*
|
||||
* Provides topological sorting and dependency analysis for features.
|
||||
* Uses a modified Kahn's algorithm that respects both dependencies and priorities.
|
||||
*/
|
||||
|
||||
import type { Feature } from "../services/feature-loader.js";
|
||||
|
||||
export interface DependencyResolutionResult {
|
||||
orderedFeatures: Feature[]; // Features in dependency-aware order
|
||||
circularDependencies: string[][]; // Groups of IDs forming cycles
|
||||
missingDependencies: Map<string, string[]>; // featureId -> missing dep IDs
|
||||
blockedFeatures: Map<string, string[]>; // featureId -> blocking dep IDs (incomplete dependencies)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves feature dependencies using topological sort with priority-aware ordering.
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Build dependency graph and detect missing/blocked dependencies
|
||||
* 2. Apply Kahn's algorithm for topological sort
|
||||
* 3. Within same dependency level, sort by priority (1=high, 2=medium, 3=low)
|
||||
* 4. Detect circular dependencies for features that can't be ordered
|
||||
*
|
||||
* @param features - Array of features to order
|
||||
* @returns Resolution result with ordered features and dependency metadata
|
||||
*/
|
||||
export function resolveDependencies(features: Feature[]): DependencyResolutionResult {
|
||||
const featureMap = new Map<string, Feature>(features.map(f => [f.id, f]));
|
||||
const inDegree = new Map<string, number>();
|
||||
const adjacencyList = new Map<string, string[]>(); // dependencyId -> [dependentIds]
|
||||
const missingDependencies = new Map<string, string[]>();
|
||||
const blockedFeatures = new Map<string, string[]>();
|
||||
|
||||
// Initialize graph structures
|
||||
for (const feature of features) {
|
||||
inDegree.set(feature.id, 0);
|
||||
adjacencyList.set(feature.id, []);
|
||||
}
|
||||
|
||||
// Build dependency graph and detect missing/blocked dependencies
|
||||
for (const feature of features) {
|
||||
const deps = feature.dependencies || [];
|
||||
for (const depId of deps) {
|
||||
if (!featureMap.has(depId)) {
|
||||
// Missing dependency - track it
|
||||
if (!missingDependencies.has(feature.id)) {
|
||||
missingDependencies.set(feature.id, []);
|
||||
}
|
||||
missingDependencies.get(feature.id)!.push(depId);
|
||||
} else {
|
||||
// Valid dependency - add edge to graph
|
||||
adjacencyList.get(depId)!.push(feature.id);
|
||||
inDegree.set(feature.id, (inDegree.get(feature.id) || 0) + 1);
|
||||
|
||||
// Check if dependency is incomplete (blocking)
|
||||
const depFeature = featureMap.get(depId)!;
|
||||
if (depFeature.status !== 'completed' && depFeature.status !== 'verified') {
|
||||
if (!blockedFeatures.has(feature.id)) {
|
||||
blockedFeatures.set(feature.id, []);
|
||||
}
|
||||
blockedFeatures.get(feature.id)!.push(depId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Kahn's algorithm with priority-aware selection
|
||||
const queue: Feature[] = [];
|
||||
const orderedFeatures: Feature[] = [];
|
||||
|
||||
// Helper to sort features by priority (lower number = higher priority)
|
||||
const sortByPriority = (a: Feature, b: Feature) =>
|
||||
(a.priority ?? 2) - (b.priority ?? 2);
|
||||
|
||||
// Start with features that have no dependencies (in-degree 0)
|
||||
for (const [id, degree] of inDegree) {
|
||||
if (degree === 0) {
|
||||
queue.push(featureMap.get(id)!);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort initial queue by priority
|
||||
queue.sort(sortByPriority);
|
||||
|
||||
// Process features in topological order
|
||||
while (queue.length > 0) {
|
||||
// Take highest priority feature from queue
|
||||
const current = queue.shift()!;
|
||||
orderedFeatures.push(current);
|
||||
|
||||
// Process features that depend on this one
|
||||
for (const dependentId of adjacencyList.get(current.id) || []) {
|
||||
const currentDegree = inDegree.get(dependentId);
|
||||
if (currentDegree === undefined) {
|
||||
throw new Error(`In-degree not initialized for feature ${dependentId}`);
|
||||
}
|
||||
const newDegree = currentDegree - 1;
|
||||
inDegree.set(dependentId, newDegree);
|
||||
|
||||
if (newDegree === 0) {
|
||||
queue.push(featureMap.get(dependentId)!);
|
||||
// Re-sort queue to maintain priority order
|
||||
queue.sort(sortByPriority);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect circular dependencies (features not in output = part of cycle)
|
||||
const circularDependencies: string[][] = [];
|
||||
const processedIds = new Set(orderedFeatures.map(f => f.id));
|
||||
|
||||
if (orderedFeatures.length < features.length) {
|
||||
// Find cycles using DFS
|
||||
const remaining = features.filter(f => !processedIds.has(f.id));
|
||||
const cycles = detectCycles(remaining, featureMap);
|
||||
circularDependencies.push(...cycles);
|
||||
|
||||
// Add remaining features at end (part of cycles)
|
||||
orderedFeatures.push(...remaining);
|
||||
}
|
||||
|
||||
return {
|
||||
orderedFeatures,
|
||||
circularDependencies,
|
||||
missingDependencies,
|
||||
blockedFeatures
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects circular dependencies using depth-first search
|
||||
*
|
||||
* @param features - Features that couldn't be topologically sorted (potential cycles)
|
||||
* @param featureMap - Map of all features by ID
|
||||
* @returns Array of cycles, where each cycle is an array of feature IDs
|
||||
*/
|
||||
function detectCycles(
|
||||
features: Feature[],
|
||||
featureMap: Map<string, Feature>
|
||||
): string[][] {
|
||||
const cycles: string[][] = [];
|
||||
const visited = new Set<string>();
|
||||
const recursionStack = new Set<string>();
|
||||
const currentPath: string[] = [];
|
||||
|
||||
function dfs(featureId: string): boolean {
|
||||
visited.add(featureId);
|
||||
recursionStack.add(featureId);
|
||||
currentPath.push(featureId);
|
||||
|
||||
const feature = featureMap.get(featureId);
|
||||
if (feature) {
|
||||
for (const depId of feature.dependencies || []) {
|
||||
if (!visited.has(depId)) {
|
||||
if (dfs(depId)) return true;
|
||||
} else if (recursionStack.has(depId)) {
|
||||
// Found cycle - extract it
|
||||
const cycleStart = currentPath.indexOf(depId);
|
||||
cycles.push(currentPath.slice(cycleStart));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentPath.pop();
|
||||
recursionStack.delete(featureId);
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const feature of features) {
|
||||
if (!visited.has(feature.id)) {
|
||||
dfs(feature.id);
|
||||
}
|
||||
}
|
||||
|
||||
return cycles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a feature's dependencies are satisfied (all complete or verified)
|
||||
*
|
||||
* @param feature - Feature to check
|
||||
* @param allFeatures - All features in the project
|
||||
* @returns true if all dependencies are satisfied, false otherwise
|
||||
*/
|
||||
export function areDependenciesSatisfied(
|
||||
feature: Feature,
|
||||
allFeatures: Feature[]
|
||||
): boolean {
|
||||
if (!feature.dependencies || feature.dependencies.length === 0) {
|
||||
return true; // No dependencies = always ready
|
||||
}
|
||||
|
||||
return feature.dependencies.every((depId: string) => {
|
||||
const dep = allFeatures.find(f => f.id === depId);
|
||||
return dep && (dep.status === 'completed' || dep.status === 'verified');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the blocking dependencies for a feature (dependencies that are incomplete)
|
||||
*
|
||||
* @param feature - Feature to check
|
||||
* @param allFeatures - All features in the project
|
||||
* @returns Array of feature IDs that are blocking this feature
|
||||
*/
|
||||
export function getBlockingDependencies(
|
||||
feature: Feature,
|
||||
allFeatures: Feature[]
|
||||
): string[] {
|
||||
if (!feature.dependencies || feature.dependencies.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return feature.dependencies.filter((depId: string) => {
|
||||
const dep = allFeatures.find(f => f.id === depId);
|
||||
return dep && dep.status !== 'completed' && dep.status !== 'verified';
|
||||
});
|
||||
}
|
||||
456
apps/server/src/lib/enhancement-prompts.ts
Normal file
456
apps/server/src/lib/enhancement-prompts.ts
Normal file
@@ -0,0 +1,456 @@
|
||||
/**
|
||||
* Enhancement Prompts Library - AI-powered text enhancement for task descriptions
|
||||
*
|
||||
* Provides prompt templates and utilities for enhancing user-written task descriptions:
|
||||
* - Improve: Transform vague requests into clear, actionable tasks
|
||||
* - Technical: Add implementation details and technical specifications
|
||||
* - Simplify: Make verbose descriptions concise and focused
|
||||
* - Acceptance: Add testable acceptance criteria
|
||||
*
|
||||
* Uses chain-of-thought prompting with few-shot examples for consistent results.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Available enhancement modes for transforming task descriptions
|
||||
*/
|
||||
export type EnhancementMode = "improve" | "technical" | "simplify" | "acceptance";
|
||||
|
||||
/**
|
||||
* Example input/output pair for few-shot learning
|
||||
*/
|
||||
export interface EnhancementExample {
|
||||
input: string;
|
||||
output: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* System prompt for the "improve" enhancement mode.
|
||||
* Transforms vague or unclear requests into clear, actionable task descriptions.
|
||||
*/
|
||||
export const IMPROVE_SYSTEM_PROMPT = `You are an expert at transforming vague, unclear, or incomplete task descriptions into clear, actionable specifications.
|
||||
|
||||
Your task is to take a user's rough description and improve it by:
|
||||
|
||||
1. ANALYZE the input:
|
||||
- Identify the core intent behind the request
|
||||
- Note any ambiguities or missing details
|
||||
- Determine what success would look like
|
||||
|
||||
2. CLARIFY the scope:
|
||||
- Define clear boundaries for the task
|
||||
- Identify implicit requirements
|
||||
- Add relevant context that may be assumed
|
||||
|
||||
3. STRUCTURE the output:
|
||||
- Write a clear, actionable title
|
||||
- Provide a concise description of what needs to be done
|
||||
- Break down into specific sub-tasks if appropriate
|
||||
|
||||
4. ENHANCE with details:
|
||||
- Add specific, measurable outcomes where possible
|
||||
- Include edge cases to consider
|
||||
- Note any dependencies or prerequisites
|
||||
|
||||
Output ONLY the improved task description. Do not include explanations, markdown formatting, or meta-commentary about your changes.`;
|
||||
|
||||
/**
|
||||
* System prompt for the "technical" enhancement mode.
|
||||
* Adds implementation details and technical specifications.
|
||||
*/
|
||||
export const TECHNICAL_SYSTEM_PROMPT = `You are a senior software engineer skilled at adding technical depth to feature descriptions.
|
||||
|
||||
Your task is to enhance a task description with technical implementation details:
|
||||
|
||||
1. ANALYZE the requirement:
|
||||
- Understand the functional goal
|
||||
- Identify the technical domain (frontend, backend, database, etc.)
|
||||
- Consider the likely tech stack based on context
|
||||
|
||||
2. ADD technical specifications:
|
||||
- Suggest specific technologies, libraries, or patterns
|
||||
- Define API contracts or data structures if relevant
|
||||
- Note performance considerations
|
||||
- Identify security implications
|
||||
|
||||
3. OUTLINE implementation approach:
|
||||
- Break down into technical sub-tasks
|
||||
- Suggest file structure or component organization
|
||||
- Note integration points with existing systems
|
||||
|
||||
4. CONSIDER edge cases:
|
||||
- Error handling requirements
|
||||
- Loading and empty states
|
||||
- Boundary conditions
|
||||
|
||||
Output ONLY the enhanced technical description. Keep it concise but comprehensive. Do not include explanations about your reasoning.`;
|
||||
|
||||
/**
|
||||
* System prompt for the "simplify" enhancement mode.
|
||||
* Makes verbose descriptions concise and focused.
|
||||
*/
|
||||
export const SIMPLIFY_SYSTEM_PROMPT = `You are an expert editor who excels at making verbose text concise without losing meaning.
|
||||
|
||||
Your task is to simplify a task description while preserving essential information:
|
||||
|
||||
1. IDENTIFY the core message:
|
||||
- Extract the primary goal or requirement
|
||||
- Note truly essential details
|
||||
- Separate nice-to-have from must-have information
|
||||
|
||||
2. ELIMINATE redundancy:
|
||||
- Remove repeated information
|
||||
- Cut unnecessary qualifiers and hedging language
|
||||
- Remove filler words and phrases
|
||||
|
||||
3. CONSOLIDATE related points:
|
||||
- Merge overlapping requirements
|
||||
- Group related items together
|
||||
- Use concise language
|
||||
|
||||
4. PRESERVE critical details:
|
||||
- Keep specific technical requirements
|
||||
- Retain important constraints
|
||||
- Maintain actionable specifics
|
||||
|
||||
Output ONLY the simplified description. Aim for 30-50% reduction in length while keeping all essential information. Do not explain your changes.`;
|
||||
|
||||
/**
|
||||
* System prompt for the "acceptance" enhancement mode.
|
||||
* Adds testable acceptance criteria to task descriptions.
|
||||
*/
|
||||
export const ACCEPTANCE_SYSTEM_PROMPT = `You are a QA specialist skilled at defining testable acceptance criteria for software features.
|
||||
|
||||
Your task is to enhance a task description by adding clear acceptance criteria:
|
||||
|
||||
1. UNDERSTAND the feature:
|
||||
- Identify all user-facing behaviors
|
||||
- Note system state changes
|
||||
- Consider different user roles or scenarios
|
||||
|
||||
2. DEFINE acceptance criteria using Given-When-Then format:
|
||||
- Given: The initial context or preconditions
|
||||
- When: The action or trigger
|
||||
- Then: The expected outcome
|
||||
|
||||
3. COVER key scenarios:
|
||||
- Happy path (successful completion)
|
||||
- Edge cases (boundary conditions)
|
||||
- Error scenarios (what should NOT happen)
|
||||
- Performance requirements if relevant
|
||||
|
||||
4. MAKE criteria testable:
|
||||
- Use specific, measurable outcomes
|
||||
- Avoid vague terms like "quickly" or "easily"
|
||||
- Include specific values where applicable
|
||||
|
||||
Output the original description followed by a clear "Acceptance Criteria:" section with numbered, testable criteria. Do not include explanations about your process.`;
|
||||
|
||||
/**
|
||||
* Few-shot examples for the "improve" enhancement mode
|
||||
*/
|
||||
export const IMPROVE_EXAMPLES: EnhancementExample[] = [
|
||||
{
|
||||
input: "add dark mode",
|
||||
output: `Add Dark Mode Theme Support
|
||||
|
||||
Implement a dark mode option that allows users to switch between light and dark themes throughout the application.
|
||||
|
||||
Requirements:
|
||||
- Add a theme toggle switch in the settings area
|
||||
- Store user's theme preference persistently
|
||||
- Apply dark theme colors to all UI components
|
||||
- Respect system preference as default if user hasn't set a preference
|
||||
- Ensure smooth transition when switching themes`,
|
||||
},
|
||||
{
|
||||
input: "fix the login bug",
|
||||
output: `Fix Login Authentication Flow
|
||||
|
||||
Investigate and resolve issues preventing users from successfully logging in to the application.
|
||||
|
||||
Requirements:
|
||||
- Identify the specific failure point in the login process
|
||||
- Ensure proper credential validation
|
||||
- Handle error states with clear user feedback
|
||||
- Verify session token is correctly stored after successful login
|
||||
- Test with various credential combinations (valid, invalid, edge cases)`,
|
||||
},
|
||||
{
|
||||
input: "make it faster",
|
||||
output: `Improve Application Performance
|
||||
|
||||
Optimize application loading and response times to enhance user experience.
|
||||
|
||||
Requirements:
|
||||
- Profile current performance to identify bottlenecks
|
||||
- Implement code splitting and lazy loading for routes/components
|
||||
- Optimize database queries and add appropriate indexes
|
||||
- Add caching for frequently accessed data
|
||||
- Set performance budget targets (e.g., LCP < 2.5s, FID < 100ms)
|
||||
- Measure and document improvements`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Few-shot examples for the "technical" enhancement mode
|
||||
*/
|
||||
export const TECHNICAL_EXAMPLES: EnhancementExample[] = [
|
||||
{
|
||||
input: "Add user profile page",
|
||||
output: `Add User Profile Page
|
||||
|
||||
Create a dedicated profile page for viewing and editing user information.
|
||||
|
||||
Technical Implementation:
|
||||
- Frontend: React component at /profile route with form validation
|
||||
- API Endpoint: GET/PUT /api/users/:id for fetching and updating profile
|
||||
- Data Model: Extend User schema with profile fields (avatar, bio, preferences)
|
||||
- File Upload: Integrate cloud storage for avatar images with client-side resizing
|
||||
- State Management: Use optimistic updates for better UX
|
||||
- Validation: Server-side validation matching client-side rules
|
||||
|
||||
Components:
|
||||
- ProfileHeader (avatar, name, edit button)
|
||||
- ProfileForm (editable fields with validation)
|
||||
- AvatarUploader (drag-drop with preview)
|
||||
|
||||
Security: Ensure users can only edit their own profile (auth middleware)`,
|
||||
},
|
||||
{
|
||||
input: "Add search functionality",
|
||||
output: `Add Search Functionality
|
||||
|
||||
Implement full-text search across application content.
|
||||
|
||||
Technical Implementation:
|
||||
- Search Engine: Use Elasticsearch or PostgreSQL full-text search
|
||||
- API: GET /api/search?q={query}&type={type}&page={page}
|
||||
- Indexing: Create search index with relevant fields, update on content changes
|
||||
- Frontend: Debounced search input (300ms) with typeahead suggestions
|
||||
- Results: Paginated results with highlighted matching text
|
||||
|
||||
Architecture:
|
||||
- SearchService class handling query building and execution
|
||||
- SearchIndex worker for background indexing
|
||||
- SearchResults component with filtering and sorting
|
||||
|
||||
Performance:
|
||||
- Implement search result caching (Redis, 5-minute TTL)
|
||||
- Limit results per page (20 items)
|
||||
- Add query complexity limits to prevent abuse`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Few-shot examples for the "simplify" enhancement mode
|
||||
*/
|
||||
export const SIMPLIFY_EXAMPLES: EnhancementExample[] = [
|
||||
{
|
||||
input: `We need to implement a feature that would allow our users to be able to export their data in various different formats. The formats we are thinking about supporting include CSV which is commonly used for spreadsheet applications, and also JSON which is good for developers, and possibly PDF for people who want a printable version. The user should be able to go somewhere in the UI and select which format they want and then download their data in that format. We should probably also show some kind of progress indicator while the export is happening since it might take a while for large datasets.`,
|
||||
output: `Add Data Export Feature
|
||||
|
||||
Allow users to export their data in CSV, JSON, or PDF format.
|
||||
|
||||
Requirements:
|
||||
- Add export button/menu in the UI
|
||||
- Support three formats: CSV (spreadsheets), JSON (developers), PDF (printing)
|
||||
- Show progress indicator during export
|
||||
- Handle large datasets gracefully`,
|
||||
},
|
||||
{
|
||||
input: `The current notification system is not working very well and users are complaining about it. Sometimes they don't receive notifications at all, and other times they receive too many notifications and feel overwhelmed. We also have had reports that the notifications are not appearing correctly on mobile devices. Additionally, some users have requested the ability to customize which types of notifications they receive. We should look into all of these issues and make the notification system work better overall.`,
|
||||
output: `Fix and Improve Notification System
|
||||
|
||||
Address reliability issues and add user controls for notifications.
|
||||
|
||||
Issues to fix:
|
||||
- Missing notifications (delivery reliability)
|
||||
- Notification overload (implement batching/digest)
|
||||
- Mobile display problems
|
||||
|
||||
Enhancements:
|
||||
- Add notification preferences (per-type controls)
|
||||
- Test across devices and platforms`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Few-shot examples for the "acceptance" enhancement mode
|
||||
*/
|
||||
export const ACCEPTANCE_EXAMPLES: EnhancementExample[] = [
|
||||
{
|
||||
input: "Add password reset functionality",
|
||||
output: `Add Password Reset Functionality
|
||||
|
||||
Allow users to reset their password via email when they forget it.
|
||||
|
||||
Acceptance Criteria:
|
||||
|
||||
1. Given a user is on the login page, when they click "Forgot Password", then they should see a password reset form requesting their email.
|
||||
|
||||
2. Given a user enters a valid registered email, when they submit the form, then they should receive a password reset email within 2 minutes.
|
||||
|
||||
3. Given a user clicks the reset link in their email, when the link is less than 1 hour old, then they should see a form to enter a new password.
|
||||
|
||||
4. Given a user enters a new valid password (min 8 chars, 1 number, 1 special char), when they submit, then their password should be updated and they should be redirected to login.
|
||||
|
||||
5. Given a user enters an unregistered email, when they submit the reset form, then they should see the same success message (security: don't reveal which emails exist).
|
||||
|
||||
6. Given a reset link older than 1 hour, when a user clicks it, then they should see an expiration message with option to request a new link.`,
|
||||
},
|
||||
{
|
||||
input: "Shopping cart checkout",
|
||||
output: `Shopping Cart Checkout
|
||||
|
||||
Implement the checkout flow for purchasing items in the shopping cart.
|
||||
|
||||
Acceptance Criteria:
|
||||
|
||||
1. Given a user has items in their cart, when they click "Checkout", then they should see an order summary with item details and total price.
|
||||
|
||||
2. Given a user is on the checkout page, when they enter valid shipping information, then the form should validate in real-time and show estimated delivery date.
|
||||
|
||||
3. Given valid shipping info is entered, when the user proceeds to payment, then they should see available payment methods (credit card, PayPal).
|
||||
|
||||
4. Given valid payment details are entered, when the user confirms the order, then the payment should be processed and order confirmation displayed within 5 seconds.
|
||||
|
||||
5. Given a successful order, when confirmation is shown, then the user should receive an email receipt and their cart should be emptied.
|
||||
|
||||
6. Given a payment failure, when the error occurs, then the user should see a clear error message and their cart should remain intact.
|
||||
|
||||
7. Given the user closes the browser during checkout, when they return, then their cart contents should still be available.`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Map of enhancement modes to their system prompts
|
||||
*/
|
||||
const SYSTEM_PROMPTS: Record<EnhancementMode, string> = {
|
||||
improve: IMPROVE_SYSTEM_PROMPT,
|
||||
technical: TECHNICAL_SYSTEM_PROMPT,
|
||||
simplify: SIMPLIFY_SYSTEM_PROMPT,
|
||||
acceptance: ACCEPTANCE_SYSTEM_PROMPT,
|
||||
};
|
||||
|
||||
/**
|
||||
* Map of enhancement modes to their few-shot examples
|
||||
*/
|
||||
const EXAMPLES: Record<EnhancementMode, EnhancementExample[]> = {
|
||||
improve: IMPROVE_EXAMPLES,
|
||||
technical: TECHNICAL_EXAMPLES,
|
||||
simplify: SIMPLIFY_EXAMPLES,
|
||||
acceptance: ACCEPTANCE_EXAMPLES,
|
||||
};
|
||||
|
||||
/**
|
||||
* Enhancement prompt configuration returned by getEnhancementPrompt
|
||||
*/
|
||||
export interface EnhancementPromptConfig {
|
||||
/** System prompt for the enhancement mode */
|
||||
systemPrompt: string;
|
||||
/** Description of what this mode does */
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Descriptions for each enhancement mode
|
||||
*/
|
||||
const MODE_DESCRIPTIONS: Record<EnhancementMode, string> = {
|
||||
improve: "Transform vague requests into clear, actionable task descriptions",
|
||||
technical: "Add implementation details and technical specifications",
|
||||
simplify: "Make verbose descriptions concise and focused",
|
||||
acceptance: "Add testable acceptance criteria to task descriptions",
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the enhancement prompt configuration for a given mode
|
||||
*
|
||||
* @param mode - The enhancement mode (falls back to 'improve' if invalid)
|
||||
* @returns The enhancement prompt configuration
|
||||
*/
|
||||
export function getEnhancementPrompt(mode: string): EnhancementPromptConfig {
|
||||
const normalizedMode = mode.toLowerCase() as EnhancementMode;
|
||||
const validMode = normalizedMode in SYSTEM_PROMPTS ? normalizedMode : "improve";
|
||||
|
||||
return {
|
||||
systemPrompt: SYSTEM_PROMPTS[validMode],
|
||||
description: MODE_DESCRIPTIONS[validMode],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the system prompt for a specific enhancement mode
|
||||
*
|
||||
* @param mode - The enhancement mode to get the prompt for
|
||||
* @returns The system prompt string
|
||||
*/
|
||||
export function getSystemPrompt(mode: EnhancementMode): string {
|
||||
return SYSTEM_PROMPTS[mode];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the few-shot examples for a specific enhancement mode
|
||||
*
|
||||
* @param mode - The enhancement mode to get examples for
|
||||
* @returns Array of input/output example pairs
|
||||
*/
|
||||
export function getExamples(mode: EnhancementMode): EnhancementExample[] {
|
||||
return EXAMPLES[mode];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a user prompt for enhancement with optional few-shot examples
|
||||
*
|
||||
* @param mode - The enhancement mode
|
||||
* @param text - The text to enhance
|
||||
* @param includeExamples - Whether to include few-shot examples (default: true)
|
||||
* @returns The formatted user prompt string
|
||||
*/
|
||||
export function buildUserPrompt(
|
||||
mode: EnhancementMode,
|
||||
text: string,
|
||||
includeExamples: boolean = true
|
||||
): string {
|
||||
const examples = includeExamples ? getExamples(mode) : [];
|
||||
|
||||
if (examples.length === 0) {
|
||||
return `Please enhance the following task description:\n\n${text}`;
|
||||
}
|
||||
|
||||
// Build few-shot examples section
|
||||
const examplesSection = examples
|
||||
.map(
|
||||
(example, index) =>
|
||||
`Example ${index + 1}:\nInput: ${example.input}\nOutput: ${example.output}`
|
||||
)
|
||||
.join("\n\n---\n\n");
|
||||
|
||||
return `Here are some examples of how to enhance task descriptions:
|
||||
|
||||
${examplesSection}
|
||||
|
||||
---
|
||||
|
||||
Now, please enhance the following task description:
|
||||
|
||||
${text}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a mode is a valid enhancement mode
|
||||
*
|
||||
* @param mode - The mode to check
|
||||
* @returns True if the mode is valid
|
||||
*/
|
||||
export function isValidEnhancementMode(mode: string): mode is EnhancementMode {
|
||||
return mode in SYSTEM_PROMPTS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available enhancement modes
|
||||
*
|
||||
* @returns Array of available enhancement mode names
|
||||
*/
|
||||
export function getAvailableEnhancementModes(): EnhancementMode[] {
|
||||
return Object.keys(SYSTEM_PROMPTS) as EnhancementMode[];
|
||||
}
|
||||
125
apps/server/src/lib/error-handler.ts
Normal file
125
apps/server/src/lib/error-handler.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Error handling utilities for standardized error classification
|
||||
*
|
||||
* Provides utilities for:
|
||||
* - Detecting abort/cancellation errors
|
||||
* - Detecting authentication errors
|
||||
* - Classifying errors by type
|
||||
* - Generating user-friendly error messages
|
||||
*/
|
||||
|
||||
/**
|
||||
* Check if an error is an abort/cancellation error
|
||||
*
|
||||
* @param error - The error to check
|
||||
* @returns True if the error is an abort error
|
||||
*/
|
||||
export function isAbortError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
(error.name === "AbortError" || error.message.includes("abort"))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is a user-initiated cancellation
|
||||
*
|
||||
* @param errorMessage - The error message to check
|
||||
* @returns True if the error is a user-initiated cancellation
|
||||
*/
|
||||
export function isCancellationError(errorMessage: string): boolean {
|
||||
const lowerMessage = errorMessage.toLowerCase();
|
||||
return (
|
||||
lowerMessage.includes("cancelled") ||
|
||||
lowerMessage.includes("canceled") ||
|
||||
lowerMessage.includes("stopped") ||
|
||||
lowerMessage.includes("aborted")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is an authentication/API key error
|
||||
*
|
||||
* @param errorMessage - The error message to check
|
||||
* @returns True if the error is authentication-related
|
||||
*/
|
||||
export function isAuthenticationError(errorMessage: string): boolean {
|
||||
return (
|
||||
errorMessage.includes("Authentication failed") ||
|
||||
errorMessage.includes("Invalid API key") ||
|
||||
errorMessage.includes("authentication_failed") ||
|
||||
errorMessage.includes("Fix external API key")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Error type classification
|
||||
*/
|
||||
export type ErrorType = "authentication" | "cancellation" | "abort" | "execution" | "unknown";
|
||||
|
||||
/**
|
||||
* Classified error information
|
||||
*/
|
||||
export interface ErrorInfo {
|
||||
type: ErrorType;
|
||||
message: string;
|
||||
isAbort: boolean;
|
||||
isAuth: boolean;
|
||||
isCancellation: boolean;
|
||||
originalError: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify an error into a specific type
|
||||
*
|
||||
* @param error - The error to classify
|
||||
* @returns Classified error information
|
||||
*/
|
||||
export function classifyError(error: unknown): ErrorInfo {
|
||||
const message = error instanceof Error ? error.message : String(error || "Unknown error");
|
||||
const isAbort = isAbortError(error);
|
||||
const isAuth = isAuthenticationError(message);
|
||||
const isCancellation = isCancellationError(message);
|
||||
|
||||
let type: ErrorType;
|
||||
if (isAuth) {
|
||||
type = "authentication";
|
||||
} else if (isAbort) {
|
||||
type = "abort";
|
||||
} else if (isCancellation) {
|
||||
type = "cancellation";
|
||||
} else if (error instanceof Error) {
|
||||
type = "execution";
|
||||
} else {
|
||||
type = "unknown";
|
||||
}
|
||||
|
||||
return {
|
||||
type,
|
||||
message,
|
||||
isAbort,
|
||||
isAuth,
|
||||
isCancellation,
|
||||
originalError: error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user-friendly error message
|
||||
*
|
||||
* @param error - The error to convert
|
||||
* @returns User-friendly error message
|
||||
*/
|
||||
export function getUserFriendlyErrorMessage(error: unknown): string {
|
||||
const info = classifyError(error);
|
||||
|
||||
if (info.isAbort) {
|
||||
return "Operation was cancelled";
|
||||
}
|
||||
|
||||
if (info.isAuth) {
|
||||
return "Authentication failed. Please check your API key.";
|
||||
}
|
||||
|
||||
return info.message;
|
||||
}
|
||||
67
apps/server/src/lib/fs-utils.ts
Normal file
67
apps/server/src/lib/fs-utils.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* File system utilities that handle symlinks safely
|
||||
*/
|
||||
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
/**
|
||||
* Create a directory, handling symlinks safely to avoid ELOOP errors.
|
||||
* If the path already exists as a directory or symlink, returns success.
|
||||
*/
|
||||
export async function mkdirSafe(dirPath: string): Promise<void> {
|
||||
const resolvedPath = path.resolve(dirPath);
|
||||
|
||||
// Check if path already exists using lstat (doesn't follow symlinks)
|
||||
try {
|
||||
const stats = await fs.lstat(resolvedPath);
|
||||
// Path exists - if it's a directory or symlink, consider it success
|
||||
if (stats.isDirectory() || stats.isSymbolicLink()) {
|
||||
return;
|
||||
}
|
||||
// It's a file - can't create directory
|
||||
throw new Error(`Path exists and is not a directory: ${resolvedPath}`);
|
||||
} catch (error: any) {
|
||||
// ENOENT means path doesn't exist - we should create it
|
||||
if (error.code !== "ENOENT") {
|
||||
// Some other error (could be ELOOP in parent path)
|
||||
// If it's ELOOP, the path involves symlinks - don't try to create
|
||||
if (error.code === "ELOOP") {
|
||||
console.warn(`[fs-utils] Symlink loop detected at ${resolvedPath}, skipping mkdir`);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Path doesn't exist, create it
|
||||
try {
|
||||
await fs.mkdir(resolvedPath, { recursive: true });
|
||||
} catch (error: any) {
|
||||
// Handle race conditions and symlink issues
|
||||
if (error.code === "EEXIST" || error.code === "ELOOP") {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path exists, handling symlinks safely.
|
||||
* Returns true if the path exists as a file, directory, or symlink.
|
||||
*/
|
||||
export async function existsSafe(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.lstat(filePath);
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
if (error.code === "ENOENT") {
|
||||
return false;
|
||||
}
|
||||
// ELOOP or other errors - path exists but is problematic
|
||||
if (error.code === "ELOOP") {
|
||||
return true; // Symlink exists, even if looping
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
135
apps/server/src/lib/image-handler.ts
Normal file
135
apps/server/src/lib/image-handler.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Image handling utilities for processing image files
|
||||
*
|
||||
* Provides utilities for:
|
||||
* - MIME type detection based on file extensions
|
||||
* - Base64 encoding of image files
|
||||
* - Content block generation for Claude SDK format
|
||||
* - Path resolution (relative/absolute)
|
||||
*/
|
||||
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
/**
|
||||
* MIME type mapping for image file extensions
|
||||
*/
|
||||
const IMAGE_MIME_TYPES: Record<string, string> = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Image data with base64 encoding and metadata
|
||||
*/
|
||||
export interface ImageData {
|
||||
base64: string;
|
||||
mimeType: string;
|
||||
filename: string;
|
||||
originalPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content block for image (Claude SDK format)
|
||||
*/
|
||||
export interface ImageContentBlock {
|
||||
type: "image";
|
||||
source: {
|
||||
type: "base64";
|
||||
media_type: string;
|
||||
data: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MIME type for an image file based on extension
|
||||
*
|
||||
* @param imagePath - Path to the image file
|
||||
* @returns MIME type string (defaults to "image/png" for unknown extensions)
|
||||
*/
|
||||
export function getMimeTypeForImage(imagePath: string): string {
|
||||
const ext = path.extname(imagePath).toLowerCase();
|
||||
return IMAGE_MIME_TYPES[ext] || "image/png";
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an image file and convert to base64 with metadata
|
||||
*
|
||||
* @param imagePath - Path to the image file
|
||||
* @returns Promise resolving to image data with base64 encoding
|
||||
* @throws Error if file cannot be read
|
||||
*/
|
||||
export async function readImageAsBase64(imagePath: string): Promise<ImageData> {
|
||||
const imageBuffer = await fs.readFile(imagePath);
|
||||
const base64Data = imageBuffer.toString("base64");
|
||||
const mimeType = getMimeTypeForImage(imagePath);
|
||||
|
||||
return {
|
||||
base64: base64Data,
|
||||
mimeType,
|
||||
filename: path.basename(imagePath),
|
||||
originalPath: imagePath,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert image paths to content blocks (Claude SDK format)
|
||||
* Handles both relative and absolute paths
|
||||
*
|
||||
* @param imagePaths - Array of image file paths
|
||||
* @param workDir - Optional working directory for resolving relative paths
|
||||
* @returns Promise resolving to array of image content blocks
|
||||
*/
|
||||
export async function convertImagesToContentBlocks(
|
||||
imagePaths: string[],
|
||||
workDir?: string
|
||||
): Promise<ImageContentBlock[]> {
|
||||
const blocks: ImageContentBlock[] = [];
|
||||
|
||||
for (const imagePath of imagePaths) {
|
||||
try {
|
||||
// Resolve to absolute path if needed
|
||||
const absolutePath = workDir && !path.isAbsolute(imagePath)
|
||||
? path.join(workDir, imagePath)
|
||||
: imagePath;
|
||||
|
||||
const imageData = await readImageAsBase64(absolutePath);
|
||||
|
||||
blocks.push({
|
||||
type: "image",
|
||||
source: {
|
||||
type: "base64",
|
||||
media_type: imageData.mimeType,
|
||||
data: imageData.base64,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[ImageHandler] Failed to load image ${imagePath}:`, error);
|
||||
// Continue processing other images
|
||||
}
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a list of image paths for text prompts
|
||||
* Formats image paths as a bulleted list for inclusion in text prompts
|
||||
*
|
||||
* @param imagePaths - Array of image file paths
|
||||
* @returns Formatted string with image paths, or empty string if no images
|
||||
*/
|
||||
export function formatImagePathsForPrompt(imagePaths: string[]): string {
|
||||
if (imagePaths.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
let text = "\n\nAttached images:\n";
|
||||
for (const imagePath of imagePaths) {
|
||||
text += `- ${imagePath}\n`;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
75
apps/server/src/lib/logger.ts
Normal file
75
apps/server/src/lib/logger.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Simple logger utility with log levels
|
||||
* Configure via LOG_LEVEL environment variable: error, warn, info, debug
|
||||
* Defaults to 'info' if not set
|
||||
*/
|
||||
|
||||
export enum LogLevel {
|
||||
ERROR = 0,
|
||||
WARN = 1,
|
||||
INFO = 2,
|
||||
DEBUG = 3,
|
||||
}
|
||||
|
||||
const LOG_LEVEL_NAMES: Record<string, LogLevel> = {
|
||||
error: LogLevel.ERROR,
|
||||
warn: LogLevel.WARN,
|
||||
info: LogLevel.INFO,
|
||||
debug: LogLevel.DEBUG,
|
||||
};
|
||||
|
||||
let currentLogLevel: LogLevel = LogLevel.INFO;
|
||||
|
||||
// Initialize log level from environment variable
|
||||
const envLogLevel = process.env.LOG_LEVEL?.toLowerCase();
|
||||
if (envLogLevel && LOG_LEVEL_NAMES[envLogLevel] !== undefined) {
|
||||
currentLogLevel = LOG_LEVEL_NAMES[envLogLevel];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a logger instance with a context prefix
|
||||
*/
|
||||
export function createLogger(context: string) {
|
||||
const prefix = `[${context}]`;
|
||||
|
||||
return {
|
||||
error: (...args: unknown[]): void => {
|
||||
if (currentLogLevel >= LogLevel.ERROR) {
|
||||
console.error(prefix, ...args);
|
||||
}
|
||||
},
|
||||
|
||||
warn: (...args: unknown[]): void => {
|
||||
if (currentLogLevel >= LogLevel.WARN) {
|
||||
console.warn(prefix, ...args);
|
||||
}
|
||||
},
|
||||
|
||||
info: (...args: unknown[]): void => {
|
||||
if (currentLogLevel >= LogLevel.INFO) {
|
||||
console.log(prefix, ...args);
|
||||
}
|
||||
},
|
||||
|
||||
debug: (...args: unknown[]): void => {
|
||||
if (currentLogLevel >= LogLevel.DEBUG) {
|
||||
console.log(prefix, "[DEBUG]", ...args);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current log level
|
||||
*/
|
||||
export function getLogLevel(): LogLevel {
|
||||
return currentLogLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the log level programmatically (useful for testing)
|
||||
*/
|
||||
export function setLogLevel(level: LogLevel): void {
|
||||
currentLogLevel = level;
|
||||
}
|
||||
|
||||
79
apps/server/src/lib/model-resolver.ts
Normal file
79
apps/server/src/lib/model-resolver.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Model resolution utilities for handling model string mapping
|
||||
*
|
||||
* Provides centralized model resolution logic:
|
||||
* - Maps Claude model aliases to full model strings
|
||||
* - Provides default models per provider
|
||||
* - Handles multiple model sources with priority
|
||||
*/
|
||||
|
||||
/**
|
||||
* Model alias mapping for Claude models
|
||||
*/
|
||||
export const CLAUDE_MODEL_MAP: Record<string, string> = {
|
||||
haiku: "claude-haiku-4-5",
|
||||
sonnet: "claude-sonnet-4-20250514",
|
||||
opus: "claude-opus-4-5-20251101",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Default models per provider
|
||||
*/
|
||||
export const DEFAULT_MODELS = {
|
||||
claude: "claude-opus-4-5-20251101",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Resolve a model key/alias to a full model string
|
||||
*
|
||||
* @param modelKey - Model key (e.g., "opus", "gpt-5.2", "claude-sonnet-4-20250514")
|
||||
* @param defaultModel - Fallback model if modelKey is undefined
|
||||
* @returns Full model string
|
||||
*/
|
||||
export function resolveModelString(
|
||||
modelKey?: string,
|
||||
defaultModel: string = DEFAULT_MODELS.claude
|
||||
): string {
|
||||
// No model specified - use default
|
||||
if (!modelKey) {
|
||||
return defaultModel;
|
||||
}
|
||||
|
||||
// Full Claude model string - pass through unchanged
|
||||
if (modelKey.includes("claude-")) {
|
||||
console.log(`[ModelResolver] Using full Claude model string: ${modelKey}`);
|
||||
return modelKey;
|
||||
}
|
||||
|
||||
// Look up Claude model alias
|
||||
const resolved = CLAUDE_MODEL_MAP[modelKey];
|
||||
if (resolved) {
|
||||
console.log(
|
||||
`[ModelResolver] Resolved model alias: "${modelKey}" -> "${resolved}"`
|
||||
);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// Unknown model key - use default
|
||||
console.warn(
|
||||
`[ModelResolver] Unknown model key "${modelKey}", using default: "${defaultModel}"`
|
||||
);
|
||||
return defaultModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective model from multiple sources
|
||||
* Priority: explicit model > session model > default
|
||||
*
|
||||
* @param explicitModel - Explicitly provided model (highest priority)
|
||||
* @param sessionModel - Model from session (medium priority)
|
||||
* @param defaultModel - Fallback default model (lowest priority)
|
||||
* @returns Resolved model string
|
||||
*/
|
||||
export function getEffectiveModel(
|
||||
explicitModel?: string,
|
||||
sessionModel?: string,
|
||||
defaultModel?: string
|
||||
): string {
|
||||
return resolveModelString(explicitModel || sessionModel, defaultModel);
|
||||
}
|
||||
79
apps/server/src/lib/prompt-builder.ts
Normal file
79
apps/server/src/lib/prompt-builder.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Prompt building utilities for constructing prompts with images
|
||||
*
|
||||
* Provides standardized prompt building that:
|
||||
* - Combines text prompts with image attachments
|
||||
* - Handles content block array generation
|
||||
* - Optionally includes image paths in text
|
||||
* - Supports both vision and non-vision models
|
||||
*/
|
||||
|
||||
import { convertImagesToContentBlocks, formatImagePathsForPrompt } from "./image-handler.js";
|
||||
|
||||
/**
|
||||
* Content that can be either simple text or structured blocks
|
||||
*/
|
||||
export type PromptContent = string | Array<{
|
||||
type: string;
|
||||
text?: string;
|
||||
source?: object;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Result of building a prompt with optional images
|
||||
*/
|
||||
export interface PromptWithImages {
|
||||
content: PromptContent;
|
||||
hasImages: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a prompt with optional image attachments
|
||||
*
|
||||
* @param basePrompt - The text prompt
|
||||
* @param imagePaths - Optional array of image file paths
|
||||
* @param workDir - Optional working directory for resolving relative paths
|
||||
* @param includeImagePaths - Whether to append image paths to the text (default: false)
|
||||
* @returns Promise resolving to prompt content and metadata
|
||||
*/
|
||||
export async function buildPromptWithImages(
|
||||
basePrompt: string,
|
||||
imagePaths?: string[],
|
||||
workDir?: string,
|
||||
includeImagePaths: boolean = false
|
||||
): Promise<PromptWithImages> {
|
||||
// No images - return plain text
|
||||
if (!imagePaths || imagePaths.length === 0) {
|
||||
return { content: basePrompt, hasImages: false };
|
||||
}
|
||||
|
||||
// Build text content with optional image path listing
|
||||
let textContent = basePrompt;
|
||||
if (includeImagePaths) {
|
||||
textContent += formatImagePathsForPrompt(imagePaths);
|
||||
}
|
||||
|
||||
// Build content blocks array
|
||||
const contentBlocks: Array<{
|
||||
type: string;
|
||||
text?: string;
|
||||
source?: object;
|
||||
}> = [];
|
||||
|
||||
// Add text block if we have text
|
||||
if (textContent.trim()) {
|
||||
contentBlocks.push({ type: "text", text: textContent });
|
||||
}
|
||||
|
||||
// Add image blocks
|
||||
const imageBlocks = await convertImagesToContentBlocks(imagePaths, workDir);
|
||||
contentBlocks.push(...imageBlocks);
|
||||
|
||||
// Return appropriate format
|
||||
const content: PromptContent =
|
||||
contentBlocks.length > 1 || contentBlocks[0]?.type === "image"
|
||||
? contentBlocks
|
||||
: textContent;
|
||||
|
||||
return { content, hasImages: true };
|
||||
}
|
||||
305
apps/server/src/lib/sdk-options.ts
Normal file
305
apps/server/src/lib/sdk-options.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* SDK Options Factory - Centralized configuration for Claude Agent SDK
|
||||
*
|
||||
* Provides presets for common use cases:
|
||||
* - Spec generation: Long-running analysis with read-only tools
|
||||
* - Feature generation: Quick JSON generation from specs
|
||||
* - Feature building: Autonomous feature implementation with full tool access
|
||||
* - Suggestions: Analysis with read-only tools
|
||||
* - Chat: Full tool access for interactive coding
|
||||
*
|
||||
* Uses model-resolver for consistent model handling across the application.
|
||||
*/
|
||||
|
||||
import type { Options } from "@anthropic-ai/claude-agent-sdk";
|
||||
import {
|
||||
resolveModelString,
|
||||
DEFAULT_MODELS,
|
||||
CLAUDE_MODEL_MAP,
|
||||
} from "./model-resolver.js";
|
||||
|
||||
/**
|
||||
* Tool presets for different use cases
|
||||
*/
|
||||
export const TOOL_PRESETS = {
|
||||
/** Read-only tools for analysis */
|
||||
readOnly: ["Read", "Glob", "Grep"] as const,
|
||||
|
||||
/** Tools for spec generation that needs to read the codebase */
|
||||
specGeneration: ["Read", "Glob", "Grep"] as const,
|
||||
|
||||
/** Full tool access for feature implementation */
|
||||
fullAccess: [
|
||||
"Read",
|
||||
"Write",
|
||||
"Edit",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"Bash",
|
||||
"WebSearch",
|
||||
"WebFetch",
|
||||
] as const,
|
||||
|
||||
/** Tools for chat/interactive mode */
|
||||
chat: [
|
||||
"Read",
|
||||
"Write",
|
||||
"Edit",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"Bash",
|
||||
"WebSearch",
|
||||
"WebFetch",
|
||||
] as const,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Max turns presets for different use cases
|
||||
*/
|
||||
export const MAX_TURNS = {
|
||||
/** Quick operations that shouldn't need many iterations */
|
||||
quick: 50,
|
||||
|
||||
/** Standard operations */
|
||||
standard: 100,
|
||||
|
||||
/** Long-running operations like full spec generation */
|
||||
extended: 250,
|
||||
|
||||
/** Very long operations that may require extensive exploration */
|
||||
maximum: 1000,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Model presets for different use cases
|
||||
*
|
||||
* These can be overridden via environment variables:
|
||||
* - AUTOMAKER_MODEL_SPEC: Model for spec generation
|
||||
* - AUTOMAKER_MODEL_FEATURES: Model for feature generation
|
||||
* - AUTOMAKER_MODEL_SUGGESTIONS: Model for suggestions
|
||||
* - AUTOMAKER_MODEL_CHAT: Model for chat
|
||||
* - AUTOMAKER_MODEL_DEFAULT: Fallback model for all operations
|
||||
*/
|
||||
export function getModelForUseCase(
|
||||
useCase: "spec" | "features" | "suggestions" | "chat" | "auto" | "default",
|
||||
explicitModel?: string
|
||||
): string {
|
||||
// Explicit model takes precedence
|
||||
if (explicitModel) {
|
||||
return resolveModelString(explicitModel);
|
||||
}
|
||||
|
||||
// Check environment variable override for this use case
|
||||
const envVarMap: Record<string, string | undefined> = {
|
||||
spec: process.env.AUTOMAKER_MODEL_SPEC,
|
||||
features: process.env.AUTOMAKER_MODEL_FEATURES,
|
||||
suggestions: process.env.AUTOMAKER_MODEL_SUGGESTIONS,
|
||||
chat: process.env.AUTOMAKER_MODEL_CHAT,
|
||||
auto: process.env.AUTOMAKER_MODEL_AUTO,
|
||||
default: process.env.AUTOMAKER_MODEL_DEFAULT,
|
||||
};
|
||||
|
||||
const envModel = envVarMap[useCase] || envVarMap.default;
|
||||
if (envModel) {
|
||||
return resolveModelString(envModel);
|
||||
}
|
||||
|
||||
const defaultModels: Record<string, string> = {
|
||||
spec: CLAUDE_MODEL_MAP["haiku"], // used to generate app specs
|
||||
features: CLAUDE_MODEL_MAP["haiku"], // used to generate features from app specs
|
||||
suggestions: CLAUDE_MODEL_MAP["haiku"], // used for suggestions
|
||||
chat: CLAUDE_MODEL_MAP["haiku"], // used for chat
|
||||
auto: CLAUDE_MODEL_MAP["opus"], // used to implement kanban cards
|
||||
default: CLAUDE_MODEL_MAP["opus"],
|
||||
};
|
||||
|
||||
return resolveModelString(defaultModels[useCase] || DEFAULT_MODELS.claude);
|
||||
}
|
||||
|
||||
/**
|
||||
* Base options that apply to all SDK calls
|
||||
*/
|
||||
function getBaseOptions(): Partial<Options> {
|
||||
return {
|
||||
permissionMode: "acceptEdits",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Options configuration for creating SDK options
|
||||
*/
|
||||
export interface CreateSdkOptionsConfig {
|
||||
/** Working directory for the agent */
|
||||
cwd: string;
|
||||
|
||||
/** Optional explicit model override */
|
||||
model?: string;
|
||||
|
||||
/** Optional session model (used as fallback if explicit model not provided) */
|
||||
sessionModel?: string;
|
||||
|
||||
/** Optional system prompt */
|
||||
systemPrompt?: string;
|
||||
|
||||
/** Optional abort controller for cancellation */
|
||||
abortController?: AbortController;
|
||||
|
||||
/** Optional output format for structured outputs */
|
||||
outputFormat?: {
|
||||
type: "json_schema";
|
||||
schema: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create SDK options for spec generation
|
||||
*
|
||||
* Configuration:
|
||||
* - Uses read-only tools for codebase analysis
|
||||
* - Extended turns for thorough exploration
|
||||
* - Opus model by default (can be overridden)
|
||||
*/
|
||||
export function createSpecGenerationOptions(
|
||||
config: CreateSdkOptionsConfig
|
||||
): Options {
|
||||
return {
|
||||
...getBaseOptions(),
|
||||
// Override permissionMode - spec generation only needs read-only tools
|
||||
// Using "acceptEdits" can cause Claude to write files to unexpected locations
|
||||
// See: https://github.com/AutoMaker-Org/automaker/issues/149
|
||||
permissionMode: "default",
|
||||
model: getModelForUseCase("spec", config.model),
|
||||
maxTurns: MAX_TURNS.maximum,
|
||||
cwd: config.cwd,
|
||||
allowedTools: [...TOOL_PRESETS.specGeneration],
|
||||
...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
|
||||
...(config.abortController && { abortController: config.abortController }),
|
||||
...(config.outputFormat && { outputFormat: config.outputFormat }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create SDK options for feature generation from specs
|
||||
*
|
||||
* Configuration:
|
||||
* - Uses read-only tools (just needs to read the spec)
|
||||
* - Quick turns since it's mostly JSON generation
|
||||
* - Sonnet model by default for speed
|
||||
*/
|
||||
export function createFeatureGenerationOptions(
|
||||
config: CreateSdkOptionsConfig
|
||||
): Options {
|
||||
return {
|
||||
...getBaseOptions(),
|
||||
// Override permissionMode - feature generation only needs read-only tools
|
||||
permissionMode: "default",
|
||||
model: getModelForUseCase("features", config.model),
|
||||
maxTurns: MAX_TURNS.quick,
|
||||
cwd: config.cwd,
|
||||
allowedTools: [...TOOL_PRESETS.readOnly],
|
||||
...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
|
||||
...(config.abortController && { abortController: config.abortController }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create SDK options for generating suggestions
|
||||
*
|
||||
* Configuration:
|
||||
* - Uses read-only tools for analysis
|
||||
* - Standard turns to allow thorough codebase exploration and structured output generation
|
||||
* - Opus model by default for thorough analysis
|
||||
*/
|
||||
export function createSuggestionsOptions(
|
||||
config: CreateSdkOptionsConfig
|
||||
): Options {
|
||||
return {
|
||||
...getBaseOptions(),
|
||||
model: getModelForUseCase("suggestions", config.model),
|
||||
maxTurns: MAX_TURNS.extended,
|
||||
cwd: config.cwd,
|
||||
allowedTools: [...TOOL_PRESETS.readOnly],
|
||||
...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
|
||||
...(config.abortController && { abortController: config.abortController }),
|
||||
...(config.outputFormat && { outputFormat: config.outputFormat }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create SDK options for chat/interactive mode
|
||||
*
|
||||
* Configuration:
|
||||
* - Full tool access for code modification
|
||||
* - Standard turns for interactive sessions
|
||||
* - Model priority: explicit model > session model > chat default
|
||||
* - Sandbox enabled for bash safety
|
||||
*/
|
||||
export function createChatOptions(config: CreateSdkOptionsConfig): Options {
|
||||
// Model priority: explicit model > session model > chat default
|
||||
const effectiveModel = config.model || config.sessionModel;
|
||||
|
||||
return {
|
||||
...getBaseOptions(),
|
||||
model: getModelForUseCase("chat", effectiveModel),
|
||||
maxTurns: MAX_TURNS.standard,
|
||||
cwd: config.cwd,
|
||||
allowedTools: [...TOOL_PRESETS.chat],
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
autoAllowBashIfSandboxed: true,
|
||||
},
|
||||
...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
|
||||
...(config.abortController && { abortController: config.abortController }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create SDK options for autonomous feature building/implementation
|
||||
*
|
||||
* Configuration:
|
||||
* - Full tool access for code modification and implementation
|
||||
* - Extended turns for thorough feature implementation
|
||||
* - Uses default model (can be overridden)
|
||||
* - Sandbox enabled for bash safety
|
||||
*/
|
||||
export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
|
||||
return {
|
||||
...getBaseOptions(),
|
||||
model: getModelForUseCase("auto", config.model),
|
||||
maxTurns: MAX_TURNS.maximum,
|
||||
cwd: config.cwd,
|
||||
allowedTools: [...TOOL_PRESETS.fullAccess],
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
autoAllowBashIfSandboxed: true,
|
||||
},
|
||||
...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
|
||||
...(config.abortController && { abortController: config.abortController }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create custom SDK options with explicit configuration
|
||||
*
|
||||
* Use this when the preset options don't fit your use case.
|
||||
*/
|
||||
export function createCustomOptions(
|
||||
config: CreateSdkOptionsConfig & {
|
||||
maxTurns?: number;
|
||||
allowedTools?: readonly string[];
|
||||
sandbox?: { enabled: boolean; autoAllowBashIfSandboxed?: boolean };
|
||||
}
|
||||
): Options {
|
||||
return {
|
||||
...getBaseOptions(),
|
||||
model: getModelForUseCase("default", config.model),
|
||||
maxTurns: config.maxTurns ?? MAX_TURNS.maximum,
|
||||
cwd: config.cwd,
|
||||
allowedTools: config.allowedTools
|
||||
? [...config.allowedTools]
|
||||
: [...TOOL_PRESETS.readOnly],
|
||||
...(config.sandbox && { sandbox: config.sandbox }),
|
||||
...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
|
||||
...(config.abortController && { abortController: config.abortController }),
|
||||
};
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
/**
|
||||
* Security utilities for path validation
|
||||
* Note: All permission checks have been disabled to allow unrestricted access
|
||||
*/
|
||||
|
||||
import path from "path";
|
||||
|
||||
// Allowed project directories - loaded from environment
|
||||
// Allowed project directories - kept for API compatibility
|
||||
const allowedPaths = new Set<string>();
|
||||
|
||||
/**
|
||||
* Initialize allowed paths from environment variable
|
||||
* Note: All paths are now allowed regardless of this setting
|
||||
*/
|
||||
export function initAllowedPaths(): void {
|
||||
const dirs = process.env.ALLOWED_PROJECT_DIRS;
|
||||
@@ -21,47 +23,36 @@ export function initAllowedPaths(): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Always allow the data directory
|
||||
const dataDir = process.env.DATA_DIR;
|
||||
if (dataDir) {
|
||||
allowedPaths.add(path.resolve(dataDir));
|
||||
}
|
||||
|
||||
const workspaceDir = process.env.WORKSPACE_DIR;
|
||||
if (workspaceDir) {
|
||||
allowedPaths.add(path.resolve(workspaceDir));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a path to the allowed list
|
||||
* Add a path to the allowed list (no-op, all paths allowed)
|
||||
*/
|
||||
export function addAllowedPath(filePath: string): void {
|
||||
allowedPaths.add(path.resolve(filePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path is allowed
|
||||
* Check if a path is allowed - always returns true
|
||||
*/
|
||||
export function isPathAllowed(filePath: string): boolean {
|
||||
const resolved = path.resolve(filePath);
|
||||
|
||||
// Check if the path is under any allowed directory
|
||||
for (const allowed of allowedPaths) {
|
||||
if (resolved.startsWith(allowed + path.sep) || resolved === allowed) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
export function isPathAllowed(_filePath: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a path and throw if not allowed
|
||||
* Validate a path - just resolves the path without checking permissions
|
||||
*/
|
||||
export function validatePath(filePath: string): string {
|
||||
const resolved = path.resolve(filePath);
|
||||
|
||||
if (!isPathAllowed(resolved)) {
|
||||
throw new Error(`Access denied: ${filePath} is not in an allowed directory`);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
return path.resolve(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
206
apps/server/src/lib/subprocess-manager.ts
Normal file
206
apps/server/src/lib/subprocess-manager.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Subprocess management utilities for CLI providers
|
||||
*/
|
||||
|
||||
import { spawn, type ChildProcess } from "child_process";
|
||||
import readline from "readline";
|
||||
|
||||
export interface SubprocessOptions {
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
env?: Record<string, string>;
|
||||
abortController?: AbortController;
|
||||
timeout?: number; // Milliseconds of no output before timeout
|
||||
}
|
||||
|
||||
export interface SubprocessResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawns a subprocess and streams JSONL output line-by-line
|
||||
*/
|
||||
export async function* spawnJSONLProcess(
|
||||
options: SubprocessOptions
|
||||
): AsyncGenerator<unknown> {
|
||||
const { command, args, cwd, env, abortController, timeout = 30000 } = options;
|
||||
|
||||
const processEnv = {
|
||||
...process.env,
|
||||
...env,
|
||||
};
|
||||
|
||||
console.log(`[SubprocessManager] Spawning: ${command} ${args.slice(0, -1).join(" ")}`);
|
||||
console.log(`[SubprocessManager] Working directory: ${cwd}`);
|
||||
|
||||
const childProcess: ChildProcess = spawn(command, args, {
|
||||
cwd,
|
||||
env: processEnv,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stderrOutput = "";
|
||||
let lastOutputTime = Date.now();
|
||||
let timeoutHandle: NodeJS.Timeout | null = null;
|
||||
|
||||
// Collect stderr for error reporting
|
||||
if (childProcess.stderr) {
|
||||
childProcess.stderr.on("data", (data: Buffer) => {
|
||||
const text = data.toString();
|
||||
stderrOutput += text;
|
||||
console.error(`[SubprocessManager] stderr: ${text}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Setup timeout detection
|
||||
const resetTimeout = () => {
|
||||
lastOutputTime = Date.now();
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
timeoutHandle = setTimeout(() => {
|
||||
const elapsed = Date.now() - lastOutputTime;
|
||||
if (elapsed >= timeout) {
|
||||
console.error(
|
||||
`[SubprocessManager] Process timeout: no output for ${timeout}ms`
|
||||
);
|
||||
childProcess.kill("SIGTERM");
|
||||
}
|
||||
}, timeout);
|
||||
};
|
||||
|
||||
resetTimeout();
|
||||
|
||||
// Setup abort handling
|
||||
if (abortController) {
|
||||
abortController.signal.addEventListener("abort", () => {
|
||||
console.log("[SubprocessManager] Abort signal received, killing process");
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
childProcess.kill("SIGTERM");
|
||||
});
|
||||
}
|
||||
|
||||
// Parse stdout as JSONL (one JSON object per line)
|
||||
if (childProcess.stdout) {
|
||||
const rl = readline.createInterface({
|
||||
input: childProcess.stdout,
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
try {
|
||||
for await (const line of rl) {
|
||||
resetTimeout();
|
||||
|
||||
if (!line.trim()) continue;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
yield parsed;
|
||||
} catch (parseError) {
|
||||
console.error(
|
||||
`[SubprocessManager] Failed to parse JSONL line: ${line}`,
|
||||
parseError
|
||||
);
|
||||
// Yield error but continue processing
|
||||
yield {
|
||||
type: "error",
|
||||
error: `Failed to parse output: ${line}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[SubprocessManager] Error reading stdout:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for process to exit
|
||||
const exitCode = await new Promise<number | null>((resolve) => {
|
||||
childProcess.on("exit", (code) => {
|
||||
console.log(`[SubprocessManager] Process exited with code: ${code}`);
|
||||
resolve(code);
|
||||
});
|
||||
|
||||
childProcess.on("error", (error) => {
|
||||
console.error("[SubprocessManager] Process error:", error);
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
|
||||
// Handle non-zero exit codes
|
||||
if (exitCode !== 0 && exitCode !== null) {
|
||||
const errorMessage = stderrOutput || `Process exited with code ${exitCode}`;
|
||||
console.error(`[SubprocessManager] Process failed: ${errorMessage}`);
|
||||
yield {
|
||||
type: "error",
|
||||
error: errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
// Process completed successfully
|
||||
if (exitCode === 0 && !stderrOutput) {
|
||||
console.log("[SubprocessManager] Process completed successfully");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawns a subprocess and collects all output
|
||||
*/
|
||||
export async function spawnProcess(
|
||||
options: SubprocessOptions
|
||||
): Promise<SubprocessResult> {
|
||||
const { command, args, cwd, env, abortController } = options;
|
||||
|
||||
const processEnv = {
|
||||
...process.env,
|
||||
...env,
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const childProcess = spawn(command, args, {
|
||||
cwd,
|
||||
env: processEnv,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
if (childProcess.stdout) {
|
||||
childProcess.stdout.on("data", (data: Buffer) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
}
|
||||
|
||||
if (childProcess.stderr) {
|
||||
childProcess.stderr.on("data", (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
}
|
||||
|
||||
// Setup abort handling
|
||||
if (abortController) {
|
||||
abortController.signal.addEventListener("abort", () => {
|
||||
childProcess.kill("SIGTERM");
|
||||
reject(new Error("Process aborted"));
|
||||
});
|
||||
}
|
||||
|
||||
childProcess.on("exit", (code) => {
|
||||
resolve({ stdout, stderr, exitCode: code });
|
||||
});
|
||||
|
||||
childProcess.on("error", (error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
96
apps/server/src/providers/base-provider.ts
Normal file
96
apps/server/src/providers/base-provider.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Abstract base class for AI model providers
|
||||
*/
|
||||
|
||||
import type {
|
||||
ProviderConfig,
|
||||
ExecuteOptions,
|
||||
ProviderMessage,
|
||||
InstallationStatus,
|
||||
ValidationResult,
|
||||
ModelDefinition,
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* Base provider class that all provider implementations must extend
|
||||
*/
|
||||
export abstract class BaseProvider {
|
||||
protected config: ProviderConfig;
|
||||
protected name: string;
|
||||
|
||||
constructor(config: ProviderConfig = {}) {
|
||||
this.config = config;
|
||||
this.name = this.getName();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the provider name (e.g., "claude", "cursor")
|
||||
*/
|
||||
abstract getName(): string;
|
||||
|
||||
/**
|
||||
* Execute a query and stream responses
|
||||
* @param options Execution options
|
||||
* @returns AsyncGenerator yielding provider messages
|
||||
*/
|
||||
abstract executeQuery(
|
||||
options: ExecuteOptions
|
||||
): AsyncGenerator<ProviderMessage>;
|
||||
|
||||
/**
|
||||
* Detect if the provider is installed and configured
|
||||
* @returns Installation status
|
||||
*/
|
||||
abstract detectInstallation(): Promise<InstallationStatus>;
|
||||
|
||||
/**
|
||||
* Get available models for this provider
|
||||
* @returns Array of model definitions
|
||||
*/
|
||||
abstract getAvailableModels(): ModelDefinition[];
|
||||
|
||||
/**
|
||||
* Validate the provider configuration
|
||||
* @returns Validation result
|
||||
*/
|
||||
validateConfig(): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Base validation (can be overridden)
|
||||
if (!this.config) {
|
||||
errors.push("Provider config is missing");
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the provider supports a specific feature
|
||||
* @param feature Feature name (e.g., "vision", "tools", "mcp")
|
||||
* @returns Whether the feature is supported
|
||||
*/
|
||||
supportsFeature(feature: string): boolean {
|
||||
// Default implementation - override in subclasses
|
||||
const commonFeatures = ["tools", "text"];
|
||||
return commonFeatures.includes(feature);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider configuration
|
||||
*/
|
||||
getConfig(): ProviderConfig {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update provider configuration
|
||||
*/
|
||||
setConfig(config: Partial<ProviderConfig>): void {
|
||||
this.config = { ...this.config, ...config };
|
||||
}
|
||||
}
|
||||
192
apps/server/src/providers/claude-provider.ts
Normal file
192
apps/server/src/providers/claude-provider.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Claude Provider - Executes queries using Claude Agent SDK
|
||||
*
|
||||
* Wraps the @anthropic-ai/claude-agent-sdk for seamless integration
|
||||
* with the provider architecture.
|
||||
*/
|
||||
|
||||
import { query, type Options } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { BaseProvider } from "./base-provider.js";
|
||||
import type {
|
||||
ExecuteOptions,
|
||||
ProviderMessage,
|
||||
InstallationStatus,
|
||||
ModelDefinition,
|
||||
} from "./types.js";
|
||||
|
||||
export class ClaudeProvider extends BaseProvider {
|
||||
getName(): string {
|
||||
return "claude";
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a query using Claude Agent SDK
|
||||
*/
|
||||
async *executeQuery(
|
||||
options: ExecuteOptions
|
||||
): AsyncGenerator<ProviderMessage> {
|
||||
const {
|
||||
prompt,
|
||||
model,
|
||||
cwd,
|
||||
systemPrompt,
|
||||
maxTurns = 20,
|
||||
allowedTools,
|
||||
abortController,
|
||||
conversationHistory,
|
||||
sdkSessionId,
|
||||
} = options;
|
||||
|
||||
// Build Claude SDK options
|
||||
const defaultTools = [
|
||||
"Read",
|
||||
"Write",
|
||||
"Edit",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"Bash",
|
||||
"WebSearch",
|
||||
"WebFetch",
|
||||
];
|
||||
const toolsToUse = allowedTools || defaultTools;
|
||||
|
||||
const sdkOptions: Options = {
|
||||
model,
|
||||
systemPrompt,
|
||||
maxTurns,
|
||||
cwd,
|
||||
allowedTools: toolsToUse,
|
||||
permissionMode: "acceptEdits",
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
autoAllowBashIfSandboxed: true,
|
||||
},
|
||||
abortController,
|
||||
// Resume existing SDK session if we have a session ID
|
||||
...(sdkSessionId && conversationHistory && conversationHistory.length > 0
|
||||
? { resume: sdkSessionId }
|
||||
: {}),
|
||||
};
|
||||
|
||||
// Build prompt payload
|
||||
let promptPayload: string | AsyncIterable<any>;
|
||||
|
||||
if (Array.isArray(prompt)) {
|
||||
// Multi-part prompt (with images)
|
||||
promptPayload = (async function* () {
|
||||
const multiPartPrompt = {
|
||||
type: "user" as const,
|
||||
session_id: "",
|
||||
message: {
|
||||
role: "user" as const,
|
||||
content: prompt,
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
};
|
||||
yield multiPartPrompt;
|
||||
})();
|
||||
} else {
|
||||
// Simple text prompt
|
||||
promptPayload = prompt;
|
||||
}
|
||||
|
||||
// Execute via Claude Agent SDK
|
||||
try {
|
||||
const stream = query({ prompt: promptPayload, options: sdkOptions });
|
||||
|
||||
// Stream messages directly - they're already in the correct format
|
||||
for await (const msg of stream) {
|
||||
yield msg as ProviderMessage;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[ClaudeProvider] executeQuery() error during execution:",
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect Claude SDK installation (always available via npm)
|
||||
*/
|
||||
async detectInstallation(): Promise<InstallationStatus> {
|
||||
// Claude SDK is always available since it's a dependency
|
||||
const hasApiKey = !!process.env.ANTHROPIC_API_KEY;
|
||||
|
||||
const status: InstallationStatus = {
|
||||
installed: true,
|
||||
method: "sdk",
|
||||
hasApiKey,
|
||||
authenticated: hasApiKey,
|
||||
};
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available Claude models
|
||||
*/
|
||||
getAvailableModels(): ModelDefinition[] {
|
||||
const models = [
|
||||
{
|
||||
id: "claude-opus-4-5-20251101",
|
||||
name: "Claude Opus 4.5",
|
||||
modelString: "claude-opus-4-5-20251101",
|
||||
provider: "anthropic",
|
||||
description: "Most capable Claude model",
|
||||
contextWindow: 200000,
|
||||
maxOutputTokens: 16000,
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
tier: "premium" as const,
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-4-20250514",
|
||||
name: "Claude Sonnet 4",
|
||||
modelString: "claude-sonnet-4-20250514",
|
||||
provider: "anthropic",
|
||||
description: "Balanced performance and cost",
|
||||
contextWindow: 200000,
|
||||
maxOutputTokens: 16000,
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
tier: "standard" as const,
|
||||
},
|
||||
{
|
||||
id: "claude-3-5-sonnet-20241022",
|
||||
name: "Claude 3.5 Sonnet",
|
||||
modelString: "claude-3-5-sonnet-20241022",
|
||||
provider: "anthropic",
|
||||
description: "Fast and capable",
|
||||
contextWindow: 200000,
|
||||
maxOutputTokens: 8000,
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
tier: "standard" as const,
|
||||
},
|
||||
{
|
||||
id: "claude-3-5-haiku-20241022",
|
||||
name: "Claude 3.5 Haiku",
|
||||
modelString: "claude-3-5-haiku-20241022",
|
||||
provider: "anthropic",
|
||||
description: "Fastest Claude model",
|
||||
contextWindow: 200000,
|
||||
maxOutputTokens: 8000,
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
tier: "basic" as const,
|
||||
},
|
||||
] satisfies ModelDefinition[];
|
||||
return models;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the provider supports a specific feature
|
||||
*/
|
||||
supportsFeature(feature: string): boolean {
|
||||
const supportedFeatures = ["tools", "text", "vision", "thinking"];
|
||||
return supportedFeatures.includes(feature);
|
||||
}
|
||||
}
|
||||
115
apps/server/src/providers/provider-factory.ts
Normal file
115
apps/server/src/providers/provider-factory.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Provider Factory - Routes model IDs to the appropriate provider
|
||||
*
|
||||
* This factory implements model-based routing to automatically select
|
||||
* the correct provider based on the model string. This makes adding
|
||||
* new providers (Cursor, OpenCode, etc.) trivial - just add one line.
|
||||
*/
|
||||
|
||||
import { BaseProvider } from "./base-provider.js";
|
||||
import { ClaudeProvider } from "./claude-provider.js";
|
||||
import type { InstallationStatus } from "./types.js";
|
||||
|
||||
export class ProviderFactory {
|
||||
/**
|
||||
* Get the appropriate provider for a given model ID
|
||||
*
|
||||
* @param modelId Model identifier (e.g., "claude-opus-4-5-20251101", "gpt-5.2", "cursor-fast")
|
||||
* @returns Provider instance for the model
|
||||
*/
|
||||
static getProviderForModel(modelId: string): BaseProvider {
|
||||
const lowerModel = modelId.toLowerCase();
|
||||
|
||||
// Claude models (claude-*, opus, sonnet, haiku)
|
||||
if (
|
||||
lowerModel.startsWith("claude-") ||
|
||||
["haiku", "sonnet", "opus"].includes(lowerModel)
|
||||
) {
|
||||
return new ClaudeProvider();
|
||||
}
|
||||
|
||||
// Future providers:
|
||||
// if (lowerModel.startsWith("cursor-")) {
|
||||
// return new CursorProvider();
|
||||
// }
|
||||
// if (lowerModel.startsWith("opencode-")) {
|
||||
// return new OpenCodeProvider();
|
||||
// }
|
||||
|
||||
// Default to Claude for unknown models
|
||||
console.warn(
|
||||
`[ProviderFactory] Unknown model prefix for "${modelId}", defaulting to Claude`
|
||||
);
|
||||
return new ClaudeProvider();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available providers
|
||||
*/
|
||||
static getAllProviders(): BaseProvider[] {
|
||||
return [
|
||||
new ClaudeProvider(),
|
||||
// Future providers...
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check installation status for all providers
|
||||
*
|
||||
* @returns Map of provider name to installation status
|
||||
*/
|
||||
static async checkAllProviders(): Promise<
|
||||
Record<string, InstallationStatus>
|
||||
> {
|
||||
const providers = this.getAllProviders();
|
||||
const statuses: Record<string, InstallationStatus> = {};
|
||||
|
||||
for (const provider of providers) {
|
||||
const name = provider.getName();
|
||||
const status = await provider.detectInstallation();
|
||||
statuses[name] = status;
|
||||
}
|
||||
|
||||
return statuses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider by name (for direct access if needed)
|
||||
*
|
||||
* @param name Provider name (e.g., "claude", "cursor")
|
||||
* @returns Provider instance or null if not found
|
||||
*/
|
||||
static getProviderByName(name: string): BaseProvider | null {
|
||||
const lowerName = name.toLowerCase();
|
||||
|
||||
switch (lowerName) {
|
||||
case "claude":
|
||||
case "anthropic":
|
||||
return new ClaudeProvider();
|
||||
|
||||
// Future providers:
|
||||
// case "cursor":
|
||||
// return new CursorProvider();
|
||||
// case "opencode":
|
||||
// return new OpenCodeProvider();
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available models from all providers
|
||||
*/
|
||||
static getAllAvailableModels() {
|
||||
const providers = this.getAllProviders();
|
||||
const allModels = [];
|
||||
|
||||
for (const provider of providers) {
|
||||
const models = provider.getAvailableModels();
|
||||
allModels.push(...models);
|
||||
}
|
||||
|
||||
return allModels;
|
||||
}
|
||||
}
|
||||
136
apps/server/src/providers/types.ts
Normal file
136
apps/server/src/providers/types.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Shared types for AI model providers
|
||||
*/
|
||||
|
||||
/**
|
||||
* Configuration for a provider instance
|
||||
*/
|
||||
export interface ProviderConfig {
|
||||
apiKey?: string;
|
||||
cliPath?: string;
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Message in conversation history
|
||||
*/
|
||||
export interface ConversationMessage {
|
||||
role: "user" | "assistant";
|
||||
content: string | Array<{ type: string; text?: string; source?: object }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for executing a query via a provider
|
||||
*/
|
||||
export interface ExecuteOptions {
|
||||
prompt: string | Array<{ type: string; text?: string; source?: object }>;
|
||||
model: string;
|
||||
cwd: string;
|
||||
systemPrompt?: string;
|
||||
maxTurns?: number;
|
||||
allowedTools?: string[];
|
||||
mcpServers?: Record<string, unknown>;
|
||||
abortController?: AbortController;
|
||||
conversationHistory?: ConversationMessage[]; // Previous messages for context
|
||||
sdkSessionId?: string; // Claude SDK session ID for resuming conversations
|
||||
}
|
||||
|
||||
/**
|
||||
* Content block in a provider message (matches Claude SDK format)
|
||||
*/
|
||||
export interface ContentBlock {
|
||||
type: "text" | "tool_use" | "thinking" | "tool_result";
|
||||
text?: string;
|
||||
thinking?: string;
|
||||
name?: string;
|
||||
input?: unknown;
|
||||
tool_use_id?: string;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token usage statistics from SDK execution
|
||||
*/
|
||||
export interface TokenUsage {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadInputTokens: number;
|
||||
cacheCreationInputTokens: number;
|
||||
totalTokens: number;
|
||||
costUSD: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-model usage breakdown from SDK result
|
||||
*/
|
||||
export interface ModelUsageData {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadInputTokens: number;
|
||||
cacheCreationInputTokens: number;
|
||||
costUSD: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Message returned by a provider (matches Claude SDK streaming format)
|
||||
*/
|
||||
export interface ProviderMessage {
|
||||
type: "assistant" | "user" | "error" | "result";
|
||||
subtype?: "success" | "error";
|
||||
session_id?: string;
|
||||
message?: {
|
||||
role: "user" | "assistant";
|
||||
content: ContentBlock[];
|
||||
};
|
||||
result?: string;
|
||||
error?: string;
|
||||
parent_tool_use_id?: string | null;
|
||||
// Token usage fields (present in result messages)
|
||||
usage?: {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cache_read_input_tokens?: number;
|
||||
cache_creation_input_tokens?: number;
|
||||
};
|
||||
total_cost_usd?: number;
|
||||
modelUsage?: Record<string, ModelUsageData>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Installation status for a provider
|
||||
*/
|
||||
export interface InstallationStatus {
|
||||
installed: boolean;
|
||||
path?: string;
|
||||
version?: string;
|
||||
method?: "cli" | "npm" | "brew" | "sdk";
|
||||
hasApiKey?: boolean;
|
||||
authenticated?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation result
|
||||
*/
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Model definition
|
||||
*/
|
||||
export interface ModelDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
modelString: string;
|
||||
provider: string;
|
||||
description: string;
|
||||
contextWindow?: number;
|
||||
maxOutputTokens?: number;
|
||||
supportsVision?: boolean;
|
||||
supportsTools?: boolean;
|
||||
tier?: "basic" | "standard" | "premium";
|
||||
default?: boolean;
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
/**
|
||||
* Agent routes - HTTP API for Claude agent interactions
|
||||
*/
|
||||
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import { AgentService } from "../services/agent-service.js";
|
||||
import type { EventEmitter } from "../lib/events.js";
|
||||
|
||||
export function createAgentRoutes(
|
||||
agentService: AgentService,
|
||||
_events: EventEmitter
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
// Start a conversation
|
||||
router.post("/start", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { sessionId, workingDirectory } = req.body as {
|
||||
sessionId: string;
|
||||
workingDirectory?: string;
|
||||
};
|
||||
|
||||
if (!sessionId) {
|
||||
res.status(400).json({ success: false, error: "sessionId is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await agentService.startConversation({
|
||||
sessionId,
|
||||
workingDirectory,
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Send a message
|
||||
router.post("/send", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { sessionId, message, workingDirectory, imagePaths } = req.body as {
|
||||
sessionId: string;
|
||||
message: string;
|
||||
workingDirectory?: string;
|
||||
imagePaths?: string[];
|
||||
};
|
||||
|
||||
if (!sessionId || !message) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ success: false, error: "sessionId and message are required" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Start the message processing (don't await - it streams via WebSocket)
|
||||
agentService
|
||||
.sendMessage({
|
||||
sessionId,
|
||||
message,
|
||||
workingDirectory,
|
||||
imagePaths,
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Agent Route] Error sending message:", error);
|
||||
});
|
||||
|
||||
// Return immediately - responses come via WebSocket
|
||||
res.json({ success: true, message: "Message sent" });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get conversation history
|
||||
router.post("/history", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { sessionId } = req.body as { sessionId: string };
|
||||
|
||||
if (!sessionId) {
|
||||
res.status(400).json({ success: false, error: "sessionId is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = agentService.getHistory(sessionId);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Stop execution
|
||||
router.post("/stop", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { sessionId } = req.body as { sessionId: string };
|
||||
|
||||
if (!sessionId) {
|
||||
res.status(400).json({ success: false, error: "sessionId is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await agentService.stopExecution(sessionId);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Clear conversation
|
||||
router.post("/clear", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { sessionId } = req.body as { sessionId: string };
|
||||
|
||||
if (!sessionId) {
|
||||
res.status(400).json({ success: false, error: "sessionId is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await agentService.clearSession(sessionId);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
15
apps/server/src/routes/agent/common.ts
Normal file
15
apps/server/src/routes/agent/common.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Common utilities for agent routes
|
||||
*/
|
||||
|
||||
import { createLogger } from "../../lib/logger.js";
|
||||
import {
|
||||
getErrorMessage as getErrorMessageShared,
|
||||
createLogError,
|
||||
} from "../common.js";
|
||||
|
||||
const logger = createLogger("Agent");
|
||||
|
||||
// Re-export shared utilities
|
||||
export { getErrorMessageShared as getErrorMessage };
|
||||
export const logError = createLogError(logger);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user