demo-dev 0.0.1-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +174 -0
- package/bin/demo-cli.js +26 -0
- package/bin/demo-dev.js +26 -0
- package/demo.dev.config.example.json +20 -0
- package/dist/index.d.ts +392 -0
- package/dist/index.js +2116 -0
- package/package.json +76 -0
- package/skills/demo-dev/SKILL.md +153 -0
- package/skills/demo-dev/references/configuration.md +102 -0
- package/skills/demo-dev/references/recipes.md +83 -0
- package/src/ai/provider.ts +254 -0
- package/src/auth/bootstrap.ts +72 -0
- package/src/browser/session.ts +43 -0
- package/src/capture/continuous-capture.ts +739 -0
- package/src/cli.ts +337 -0
- package/src/config/project.ts +183 -0
- package/src/github/comment.ts +134 -0
- package/src/index.ts +10 -0
- package/src/lib/data-uri.ts +21 -0
- package/src/lib/fs.ts +7 -0
- package/src/lib/git.ts +59 -0
- package/src/lib/media.ts +23 -0
- package/src/orchestrate.ts +166 -0
- package/src/planner/heuristic.ts +180 -0
- package/src/planner/index.ts +26 -0
- package/src/planner/llm.ts +85 -0
- package/src/planner/openai.ts +77 -0
- package/src/planner/prompt.ts +331 -0
- package/src/planner/refine.ts +155 -0
- package/src/planner/schema.ts +62 -0
- package/src/presentation/polish.ts +84 -0
- package/src/probe/page-probe.ts +225 -0
- package/src/render/browser-frame.ts +176 -0
- package/src/render/ffmpeg-compose.ts +779 -0
- package/src/render/visual-plan.ts +422 -0
- package/src/setup/doctor.ts +158 -0
- package/src/setup/init.ts +90 -0
- package/src/types.ts +105 -0
- package/src/voice/script.ts +42 -0
- package/src/voice/tts.ts +286 -0
- package/tsconfig.json +16 -0
package/package.json
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "demo-dev",
|
|
3
|
+
"version": "0.0.1-alpha.0",
|
|
4
|
+
"description": "Generate polished product demo videos with one command. Just give a URL and a prompt.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"./package.json": "./package.json"
|
|
16
|
+
},
|
|
17
|
+
"bin": {
|
|
18
|
+
"demo-dev": "bin/demo-cli.js"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"bin",
|
|
22
|
+
"dist",
|
|
23
|
+
"src",
|
|
24
|
+
"skills",
|
|
25
|
+
"README.md",
|
|
26
|
+
"demo.dev.config.example.json",
|
|
27
|
+
"tsconfig.json"
|
|
28
|
+
],
|
|
29
|
+
"keywords": [
|
|
30
|
+
"pi-package",
|
|
31
|
+
"agent-skill",
|
|
32
|
+
"pr-demo",
|
|
33
|
+
"playwright",
|
|
34
|
+
"ffmpeg",
|
|
35
|
+
"ghost-cursor"
|
|
36
|
+
],
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=20"
|
|
39
|
+
},
|
|
40
|
+
"pi": {
|
|
41
|
+
"skills": [
|
|
42
|
+
"./skills"
|
|
43
|
+
]
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "tsup src/index.ts --dts --format esm --outDir dist --clean",
|
|
47
|
+
"demo-cli": "tsx src/cli.ts",
|
|
48
|
+
"demo": "tsx src/cli.ts demo",
|
|
49
|
+
"init": "tsx src/cli.ts init",
|
|
50
|
+
"doctor": "tsx src/cli.ts doctor",
|
|
51
|
+
"config": "tsx src/cli.ts config",
|
|
52
|
+
"providers": "tsx src/cli.ts providers",
|
|
53
|
+
"plan": "tsx src/cli.ts plan",
|
|
54
|
+
"probe": "tsx src/cli.ts probe",
|
|
55
|
+
"auth": "tsx src/cli.ts auth",
|
|
56
|
+
"capture": "tsx src/cli.ts capture",
|
|
57
|
+
"voice": "tsx src/cli.ts voice",
|
|
58
|
+
"render": "tsx src/cli.ts render",
|
|
59
|
+
"comment": "tsx src/cli.ts comment",
|
|
60
|
+
"prepack": "npm run build",
|
|
61
|
+
"typecheck": "tsc --noEmit"
|
|
62
|
+
},
|
|
63
|
+
"dependencies": {
|
|
64
|
+
"@clack/prompts": "^1.2.0",
|
|
65
|
+
"citty": "^0.2.2",
|
|
66
|
+
"ghost-cursor-playwright": "^2.1.0",
|
|
67
|
+
"playwright": "^1.52.0",
|
|
68
|
+
"tsx": "^4.19.3",
|
|
69
|
+
"zod": "^4.3.6"
|
|
70
|
+
},
|
|
71
|
+
"devDependencies": {
|
|
72
|
+
"@types/node": "^20.17.30",
|
|
73
|
+
"tsup": "^8.5.1",
|
|
74
|
+
"typescript": "^5.8.3"
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: demo-dev
|
|
3
|
+
description: Generate PR demo videos for any web app repo using demo.dev. Use when a user wants an agent to turn a PR, diff, feature branch, or live app flow into a product demo video, especially for authenticated SaaS flows, launch-style walkthroughs, or PR review artifacts.
|
|
4
|
+
allowed-tools: Bash Read Edit Write
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# demo-dev
|
|
8
|
+
|
|
9
|
+
Use this skill when the user wants an agent to create or update a demo video with the `demo.dev` pipeline.
|
|
10
|
+
|
|
11
|
+
## What this skill does
|
|
12
|
+
|
|
13
|
+
- Reads project config from `demo.dev.config.json`
|
|
14
|
+
- Runs the pipeline from diff -> plan -> probe -> capture -> render
|
|
15
|
+
- Supports authenticated products via storage state
|
|
16
|
+
- Can also create manual plans for feature-specific demos
|
|
17
|
+
- Produces artifacts like mp4, manifest, captures, and PR comments
|
|
18
|
+
|
|
19
|
+
## Default workflow
|
|
20
|
+
|
|
21
|
+
### 1. Inspect config
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
demo-dev config
|
|
25
|
+
demo-dev config --field baseUrl
|
|
26
|
+
demo-dev doctor
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
If config is missing, run `demo-dev init` or create one from [`../../demo.dev.config.example.json`](../../demo.dev.config.example.json).
|
|
30
|
+
|
|
31
|
+
See [references/configuration.md](references/configuration.md).
|
|
32
|
+
|
|
33
|
+
### 2. If auth is required, bootstrap login
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
demo-dev auth:bootstrap \
|
|
37
|
+
--email you@example.com \
|
|
38
|
+
--password 'your-password'
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
This writes storage state using the configured path or `artifacts/storage-state.json`.
|
|
42
|
+
|
|
43
|
+
### 3. Run the full pipeline
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
demo-dev pr-demo
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Or explicitly:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
demo-dev pr-demo --base-url http://localhost:3000 --base-ref origin/main
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### 4. Re-render only when capture and manifest already exist
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
demo-dev render --manifest artifacts/render-manifest.json --out artifacts/pr-demo.mp4
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## When to use manual plans
|
|
62
|
+
|
|
63
|
+
Use a manual plan when:
|
|
64
|
+
- the feature is behind auth or deep navigation
|
|
65
|
+
- the planner cannot infer the correct route from diff
|
|
66
|
+
- the user wants a specific product moment, such as AI editing, onboarding, settings, inbox, or dashboards
|
|
67
|
+
- the user wants a more launch-style product film rather than raw replay
|
|
68
|
+
|
|
69
|
+
Place plans in an artifacts folder such as:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
artifacts/manual-plan.json
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Then run capture / manifest / render around that plan.
|
|
76
|
+
|
|
77
|
+
See [references/recipes.md](references/recipes.md).
|
|
78
|
+
|
|
79
|
+
## Recommended agent behavior
|
|
80
|
+
|
|
81
|
+
1. Prefer stable, product-facing flows over exhaustive QA coverage.
|
|
82
|
+
2. For the same route, keep one screen object and move the camera instead of cutting to a new screen.
|
|
83
|
+
3. Prefer stable states over loading transitions.
|
|
84
|
+
4. If auth is required, verify storage state early.
|
|
85
|
+
5. If planner output is weak, create a tighter manual plan instead of forcing bad automation.
|
|
86
|
+
6. Keep copy concise and product-first.
|
|
87
|
+
|
|
88
|
+
## Core commands
|
|
89
|
+
|
|
90
|
+
Preferred first-class CLI:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
demo-dev config
|
|
94
|
+
demo-dev providers
|
|
95
|
+
demo-dev plan
|
|
96
|
+
demo-dev probe
|
|
97
|
+
demo-dev auth:bootstrap --email you@example.com --password 'your-password'
|
|
98
|
+
demo-dev capture
|
|
99
|
+
demo-dev voice
|
|
100
|
+
demo-dev manifest
|
|
101
|
+
demo-dev render --manifest artifacts/render-manifest.json --out artifacts/pr-demo.mp4
|
|
102
|
+
demo-dev comment --output-dir artifacts --pr-number 123
|
|
103
|
+
demo-dev pr-demo
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Repo-local fallback:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
npm run config
|
|
110
|
+
npm run providers
|
|
111
|
+
npm run plan
|
|
112
|
+
npm run probe
|
|
113
|
+
npm run auth:bootstrap
|
|
114
|
+
npm run capture
|
|
115
|
+
npm run voice
|
|
116
|
+
npm run manifest
|
|
117
|
+
npm run render -- --manifest artifacts/render-manifest.json --out artifacts/pr-demo.mp4
|
|
118
|
+
npm run comment -- --output-dir artifacts --pr-number 123
|
|
119
|
+
npm run pr-demo
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Outputs to check
|
|
123
|
+
|
|
124
|
+
- `artifacts/demo-context.json`
|
|
125
|
+
- `artifacts/demo-plan.initial.json`
|
|
126
|
+
- `artifacts/page-probes.json`
|
|
127
|
+
- `artifacts/demo-plan.json`
|
|
128
|
+
- `artifacts/captures.json`
|
|
129
|
+
- `artifacts/render-manifest.json`
|
|
130
|
+
- `artifacts/pr-demo.mp4`
|
|
131
|
+
|
|
132
|
+
## Troubleshooting
|
|
133
|
+
|
|
134
|
+
- If the app requires auth, run `auth:bootstrap` first.
|
|
135
|
+
- If the video is wrong, inspect `captures.json` and `render-manifest.json` before changing renderer code.
|
|
136
|
+
- If the planner misses the feature, create a manual plan.
|
|
137
|
+
- If the route is unstable, add project hints in config.
|
|
138
|
+
|
|
139
|
+
## Share this skill
|
|
140
|
+
|
|
141
|
+
Other users can install this repo as a pi package or point pi at this repo's `skills/` directory.
|
|
142
|
+
|
|
143
|
+
Example:
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
pi install /absolute/path/to/demo.dev
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
or
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
pi install git:github.com/your-org/demo.dev
|
|
153
|
+
```
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# demo.dev skill configuration
|
|
2
|
+
|
|
3
|
+
## Quick bootstrap
|
|
4
|
+
|
|
5
|
+
A fast way to bootstrap a repo is:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
demo-dev init
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
That writes a starter config plus the workflow template.
|
|
12
|
+
|
|
13
|
+
## Minimal config
|
|
14
|
+
|
|
15
|
+
Create `demo.dev.config.json` in the target repo:
|
|
16
|
+
|
|
17
|
+
```json
|
|
18
|
+
{
|
|
19
|
+
"projectName": "My App",
|
|
20
|
+
"baseUrl": "http://localhost:3000",
|
|
21
|
+
"readyUrl": "http://localhost:3000",
|
|
22
|
+
"devCommand": "npm run dev",
|
|
23
|
+
"baseRef": "origin/main",
|
|
24
|
+
"outputDir": "artifacts",
|
|
25
|
+
"preferredRoutes": ["/", "/dashboard"],
|
|
26
|
+
"featureHints": ["home", "dashboard"]
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Auth-enabled config
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{
|
|
34
|
+
"projectName": "My SaaS",
|
|
35
|
+
"baseUrl": "https://app.example.com",
|
|
36
|
+
"readyUrl": "https://app.example.com",
|
|
37
|
+
"baseRef": "origin/main",
|
|
38
|
+
"outputDir": "artifacts",
|
|
39
|
+
"storageStatePath": "artifacts/storage-state.json",
|
|
40
|
+
"saveStorageStatePath": "artifacts/storage-state.json",
|
|
41
|
+
"preferredRoutes": ["/dashboard", "/settings"],
|
|
42
|
+
"featureHints": ["dashboard", "settings"],
|
|
43
|
+
"authRequiredRoutes": ["/dashboard", "/settings"],
|
|
44
|
+
"auth": {
|
|
45
|
+
"loginPath": "/login",
|
|
46
|
+
"emailTarget": { "strategy": "css", "value": "#email" },
|
|
47
|
+
"passwordTarget": { "strategy": "css", "value": "#password" },
|
|
48
|
+
"submitTarget": { "strategy": "role", "role": "button", "name": "Login" },
|
|
49
|
+
"postSubmitWaitMs": 1500
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Useful overrides
|
|
55
|
+
|
|
56
|
+
You can override config through:
|
|
57
|
+
|
|
58
|
+
- CLI flags like `--base-url`, `--output-dir`, `--base-ref`
|
|
59
|
+
- env vars like `DEMO_STORAGE_STATE`, `DEMO_SAVE_STORAGE_STATE`, `DEMO_CONFIG`
|
|
60
|
+
- GitHub Variables in workflow:
|
|
61
|
+
- `DEMO_BASE_URL`
|
|
62
|
+
- `DEMO_READY_URL`
|
|
63
|
+
- `DEMO_DEV_COMMAND`
|
|
64
|
+
- `DEMO_OUTPUT_DIR`
|
|
65
|
+
|
|
66
|
+
## AI and TTS environment variables
|
|
67
|
+
|
|
68
|
+
`demo.dev` uses explicit `DEMO_*` env vars for provider credentials.
|
|
69
|
+
It does not fall back to generic provider keys such as `OPENAI_API_KEY` or `ELEVENLABS_API_KEY`.
|
|
70
|
+
|
|
71
|
+
Common values:
|
|
72
|
+
|
|
73
|
+
- `DEMO_OPENAI_API_KEY`
|
|
74
|
+
- `DEMO_OPENAI_BASE_URL`
|
|
75
|
+
- `DEMO_OPENAI_MODEL`
|
|
76
|
+
- `DEMO_AI_PROVIDER`
|
|
77
|
+
- `DEMO_AI_MODEL`
|
|
78
|
+
- `DEMO_TTS_PROVIDER`
|
|
79
|
+
- `DEMO_TTS_MODEL`
|
|
80
|
+
- `DEMO_TTS_VOICE`
|
|
81
|
+
- `DEMO_ELEVENLABS_API_KEY`
|
|
82
|
+
- `DEMO_ELEVENLABS_VOICE_ID`
|
|
83
|
+
|
|
84
|
+
Example:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
DEMO_OPENAI_API_KEY=your_openai_key
|
|
88
|
+
DEMO_AI_PROVIDER=openai
|
|
89
|
+
DEMO_TTS_PROVIDER=openai
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Suggested setup for other repos
|
|
93
|
+
|
|
94
|
+
1. Copy `demo.dev.config.example.json` into the target repo.
|
|
95
|
+
2. Adjust `baseUrl`, `devCommand`, and `auth`.
|
|
96
|
+
3. Validate the repo:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
demo-dev doctor
|
|
100
|
+
demo-dev config
|
|
101
|
+
demo-dev pr-demo
|
|
102
|
+
```
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# demo.dev skill recipes
|
|
2
|
+
|
|
3
|
+
## Recipe: run on a simple local app
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install
|
|
7
|
+
npx playwright install chromium
|
|
8
|
+
demo-dev doctor
|
|
9
|
+
demo-dev pr-demo
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Recipe: authenticated SaaS
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
demo-dev auth:bootstrap \
|
|
16
|
+
--email you@example.com \
|
|
17
|
+
--password 'your-password'
|
|
18
|
+
|
|
19
|
+
demo-dev pr-demo
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Recipe: OpenAI for planning and TTS
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
DEMO_OPENAI_API_KEY=your_openai_key \
|
|
26
|
+
DEMO_AI_PROVIDER=openai \
|
|
27
|
+
DEMO_TTS_PROVIDER=openai \
|
|
28
|
+
demo-dev pr-demo
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
This repo only reads `DEMO_*` provider keys for AI and TTS credentials.
|
|
32
|
+
|
|
33
|
+
## Recipe: inspect plan quality first
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
demo-dev plan
|
|
37
|
+
demo-dev probe
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Use this when the feature is not obvious from the diff.
|
|
41
|
+
|
|
42
|
+
## Recipe: re-render without recapturing
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
demo-dev render --manifest artifacts/render-manifest.json --out artifacts/pr-demo.mp4
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Recipe: add background music
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
DEMO_BGM_PATH=./assets/music/bed.mp3 \
|
|
52
|
+
DEMO_BGM_VOLUME=0.14 \
|
|
53
|
+
DEMO_BGM_DUCKING=0.28 \
|
|
54
|
+
demo-dev pr-demo
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
This will loop the music bed across the full video, fade it in and out, and lower it automatically while narration is playing.
|
|
58
|
+
|
|
59
|
+
## Recipe: manual feature film
|
|
60
|
+
|
|
61
|
+
Use a manual plan when you need a specific flow like:
|
|
62
|
+
- AI editing
|
|
63
|
+
- inbox triage
|
|
64
|
+
- onboarding
|
|
65
|
+
- dashboard walkthrough
|
|
66
|
+
|
|
67
|
+
Suggested flow:
|
|
68
|
+
|
|
69
|
+
1. Create `artifacts/manual-plan.json`
|
|
70
|
+
2. Run capture against that plan
|
|
71
|
+
3. Build manifest
|
|
72
|
+
4. Render mp4
|
|
73
|
+
|
|
74
|
+
## Recipe: PR automation
|
|
75
|
+
|
|
76
|
+
In CI, run:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
demo-dev pr-demo
|
|
80
|
+
demo-dev comment --output-dir artifacts
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
If the app needs a server, set `devCommand` and `readyUrl` in config or GitHub Variables.
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
|
|
10
|
+
export type AiProvider = "cursor" | "claude" | "codex" | "openai";
|
|
11
|
+
|
|
12
|
+
interface ProviderConfig {
|
|
13
|
+
provider: AiProvider | "auto";
|
|
14
|
+
model?: string;
|
|
15
|
+
mandatory: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const extractJson = (text: string) => {
|
|
19
|
+
const fenced = text.match(/```json\s*([\s\S]*?)```/i)?.[1];
|
|
20
|
+
if (fenced) return fenced.trim();
|
|
21
|
+
|
|
22
|
+
const firstBrace = text.indexOf("{");
|
|
23
|
+
const lastBrace = text.lastIndexOf("}");
|
|
24
|
+
if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
|
|
25
|
+
return text.slice(firstBrace, lastBrace + 1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return text.trim();
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const runCommand = async (command: string, args: string[], stdin?: string) => {
|
|
32
|
+
if (stdin) {
|
|
33
|
+
const { spawn } = await import("node:child_process");
|
|
34
|
+
return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
|
|
35
|
+
const child = spawn(command, args, { cwd: process.cwd(), stdio: ["pipe", "pipe", "pipe"] });
|
|
36
|
+
let stdout = "";
|
|
37
|
+
let stderr = "";
|
|
38
|
+
child.stdout.on("data", (d: Buffer) => { stdout += d.toString(); });
|
|
39
|
+
child.stderr.on("data", (d: Buffer) => { stderr += d.toString(); });
|
|
40
|
+
child.on("error", reject);
|
|
41
|
+
child.on("close", (code) => {
|
|
42
|
+
if (code !== 0) reject(new Error(`${command} exited with ${code}: ${stderr}`));
|
|
43
|
+
else resolve({ stdout, stderr });
|
|
44
|
+
});
|
|
45
|
+
child.stdin.write(stdin);
|
|
46
|
+
child.stdin.end();
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
const { stdout, stderr } = await execFileAsync(command, args, {
|
|
50
|
+
cwd: process.cwd(),
|
|
51
|
+
maxBuffer: 1024 * 1024 * 20,
|
|
52
|
+
});
|
|
53
|
+
return { stdout, stderr };
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const commandExists = async (command: string) => {
|
|
57
|
+
try {
|
|
58
|
+
await execFileAsync("which", [command]);
|
|
59
|
+
return true;
|
|
60
|
+
} catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const getConfig = (): ProviderConfig => ({
|
|
66
|
+
provider: (process.env.DEMO_AI_PROVIDER as ProviderConfig["provider"]) ?? "auto",
|
|
67
|
+
model: process.env.DEMO_AI_MODEL,
|
|
68
|
+
mandatory: process.env.DEMO_AI_MANDATORY !== "false",
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const getOpenAiConfig = () => {
|
|
72
|
+
const apiKey = process.env.DEMO_OPENAI_API_KEY;
|
|
73
|
+
const baseUrl = process.env.DEMO_OPENAI_BASE_URL ?? "https://api.openai.com/v1";
|
|
74
|
+
const model = process.env.DEMO_OPENAI_MODEL ?? process.env.DEMO_AI_MODEL ?? "gpt-4.1-mini";
|
|
75
|
+
return { apiKey, baseUrl, model };
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const requestFromOpenAi = async <T>(options: {
|
|
79
|
+
system: string;
|
|
80
|
+
prompt: string;
|
|
81
|
+
schema: z.ZodType<T>;
|
|
82
|
+
}) => {
|
|
83
|
+
const config = getOpenAiConfig();
|
|
84
|
+
if (!config.apiKey) throw new Error("OpenAI API key not configured. Set DEMO_OPENAI_API_KEY.");
|
|
85
|
+
|
|
86
|
+
const response = await fetch(`${config.baseUrl}/chat/completions`, {
|
|
87
|
+
method: "POST",
|
|
88
|
+
headers: {
|
|
89
|
+
"content-type": "application/json",
|
|
90
|
+
authorization: `Bearer ${config.apiKey}`,
|
|
91
|
+
},
|
|
92
|
+
body: JSON.stringify({
|
|
93
|
+
model: config.model,
|
|
94
|
+
temperature: 0.2,
|
|
95
|
+
response_format: { type: "json_object" },
|
|
96
|
+
messages: [
|
|
97
|
+
{ role: "system", content: options.system },
|
|
98
|
+
{ role: "user", content: options.prompt },
|
|
99
|
+
],
|
|
100
|
+
}),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
if (!response.ok) {
|
|
104
|
+
throw new Error(`OpenAI request failed: ${response.status} ${await response.text()}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const payload = z
|
|
108
|
+
.object({
|
|
109
|
+
choices: z.array(z.object({ message: z.object({ content: z.string().nullable().optional() }) })),
|
|
110
|
+
})
|
|
111
|
+
.parse(await response.json());
|
|
112
|
+
|
|
113
|
+
const content = payload.choices[0]?.message.content;
|
|
114
|
+
if (!content) throw new Error("OpenAI returned empty content");
|
|
115
|
+
return options.schema.parse(JSON.parse(extractJson(content)));
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const requestFromCursor = async <T>(options: {
|
|
119
|
+
system: string;
|
|
120
|
+
prompt: string;
|
|
121
|
+
schema: z.ZodType<T>;
|
|
122
|
+
model?: string;
|
|
123
|
+
}) => {
|
|
124
|
+
if (!(await commandExists("cursor-agent"))) throw new Error("cursor-agent not found");
|
|
125
|
+
|
|
126
|
+
const stdinContent = `${options.system}\n\n${options.prompt}`;
|
|
127
|
+
const args = ["--print", "--output-format", "json", "--trust", "--mode", "plan"];
|
|
128
|
+
if (options.model) args.push("--model", options.model);
|
|
129
|
+
// Pass prompt via stdin to avoid exceeding argument length limits
|
|
130
|
+
args.push("-");
|
|
131
|
+
|
|
132
|
+
const { stdout } = await runCommand("cursor-agent", args, stdinContent);
|
|
133
|
+
const payload = JSON.parse(stdout) as { result?: string; is_error?: boolean };
|
|
134
|
+
if (payload.is_error) throw new Error(payload.result ?? "Cursor provider returned error");
|
|
135
|
+
if (!payload.result) throw new Error("Cursor provider returned empty result");
|
|
136
|
+
return options.schema.parse(JSON.parse(extractJson(payload.result)));
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const requestFromClaude = async <T>(options: {
|
|
140
|
+
system: string;
|
|
141
|
+
prompt: string;
|
|
142
|
+
schema: z.ZodType<T>;
|
|
143
|
+
model?: string;
|
|
144
|
+
}) => {
|
|
145
|
+
if (!(await commandExists("claude"))) throw new Error("claude not found");
|
|
146
|
+
|
|
147
|
+
const stdinContent = `${options.prompt}\n\nReturn strict JSON only.`;
|
|
148
|
+
const args = [
|
|
149
|
+
"-p",
|
|
150
|
+
"--output-format",
|
|
151
|
+
"json",
|
|
152
|
+
"--system-prompt",
|
|
153
|
+
options.system,
|
|
154
|
+
"--allowedTools",
|
|
155
|
+
"",
|
|
156
|
+
];
|
|
157
|
+
if (options.model) args.push("--model", options.model);
|
|
158
|
+
// Pass prompt via stdin to avoid exceeding argument length limits
|
|
159
|
+
args.push("-");
|
|
160
|
+
|
|
161
|
+
const { stdout } = await runCommand("claude", args, stdinContent);
|
|
162
|
+
const payload = JSON.parse(stdout) as { result?: string; is_error?: boolean };
|
|
163
|
+
if (payload.is_error) throw new Error(payload.result ?? "Claude provider returned error");
|
|
164
|
+
if (!payload.result) throw new Error("Claude provider returned empty result");
|
|
165
|
+
return options.schema.parse(JSON.parse(extractJson(payload.result)));
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const requestFromCodex = async <T>(options: {
|
|
169
|
+
system: string;
|
|
170
|
+
prompt: string;
|
|
171
|
+
schema: z.ZodType<T>;
|
|
172
|
+
model?: string;
|
|
173
|
+
}) => {
|
|
174
|
+
if (!(await commandExists("codex"))) throw new Error("codex not found");
|
|
175
|
+
|
|
176
|
+
const tempDir = await mkdtemp(join(tmpdir(), "demo-dev-codex-"));
|
|
177
|
+
const outputPath = join(tempDir, "last-message.json");
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
const fullPrompt = `${options.system}\n\n${options.prompt}\n\nReturn strict JSON only.`;
|
|
181
|
+
const args = [
|
|
182
|
+
"exec",
|
|
183
|
+
"--skip-git-repo-check",
|
|
184
|
+
"--sandbox",
|
|
185
|
+
"read-only",
|
|
186
|
+
"--output-last-message",
|
|
187
|
+
outputPath,
|
|
188
|
+
];
|
|
189
|
+
if (options.model) args.push("--model", options.model);
|
|
190
|
+
args.push(fullPrompt);
|
|
191
|
+
|
|
192
|
+
await runCommand("codex", args);
|
|
193
|
+
const content = await readFile(outputPath, "utf8");
|
|
194
|
+
return options.schema.parse(JSON.parse(extractJson(content)));
|
|
195
|
+
} finally {
|
|
196
|
+
await rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const PROVIDERS: readonly AiProvider[] = ["cursor", "claude", "codex", "openai"];
|
|
201
|
+
|
|
202
|
+
export const listAvailableProviders = async () => {
|
|
203
|
+
const openaiConfig = getOpenAiConfig();
|
|
204
|
+
return {
|
|
205
|
+
cursor: await commandExists("cursor-agent"),
|
|
206
|
+
claude: await commandExists("claude"),
|
|
207
|
+
codex: await commandExists("codex"),
|
|
208
|
+
openai: Boolean(openaiConfig.apiKey),
|
|
209
|
+
selected: getConfig().provider,
|
|
210
|
+
};
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
export const requestAiJson = async <T>(options: {
|
|
214
|
+
system: string;
|
|
215
|
+
prompt: string;
|
|
216
|
+
schema: z.ZodType<T>;
|
|
217
|
+
temperature?: number;
|
|
218
|
+
}) => {
|
|
219
|
+
const config = getConfig();
|
|
220
|
+
const providers = config.provider === "auto" ? PROVIDERS : [config.provider];
|
|
221
|
+
const errors: string[] = [];
|
|
222
|
+
|
|
223
|
+
for (const provider of providers) {
|
|
224
|
+
let prompt = options.prompt;
|
|
225
|
+
|
|
226
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
227
|
+
try {
|
|
228
|
+
switch (provider) {
|
|
229
|
+
case "cursor":
|
|
230
|
+
return await requestFromCursor({ ...options, prompt, model: config.model });
|
|
231
|
+
case "claude":
|
|
232
|
+
return await requestFromClaude({ ...options, prompt, model: config.model });
|
|
233
|
+
case "codex":
|
|
234
|
+
return await requestFromCodex({ ...options, prompt, model: config.model });
|
|
235
|
+
case "openai":
|
|
236
|
+
return await requestFromOpenAi({ ...options, prompt });
|
|
237
|
+
}
|
|
238
|
+
} catch (error) {
|
|
239
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
240
|
+
if (attempt === 0) {
|
|
241
|
+
prompt = `${options.prompt}\n\nYour previous response failed validation with this error:\n${message}\n\nReturn corrected strict JSON only.`;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
errors.push(`${provider}: ${message}`);
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (config.provider !== "auto") break;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (!config.mandatory) return null;
|
|
253
|
+
throw new Error(`No AI provider succeeded. ${errors.join(" | ")}`);
|
|
254
|
+
};
|