Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ builds:
- amd64
- arm64
ldflags:
- -s -w -X main.version={{.Version}}
- -s -w -X main.version={{.Version}} -X main.commit={{.ShortCommit}}

archives:
- formats:
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ Secure by default. Runs anywhere — local, container, cloud, air-gapped.
## Quick Start

```bash
# Install
brew install initializ/tap/forge # or download binary from GitHub Releases
# Install (pick one)
brew install initializ/tap/forge
curl -sSL https://raw.githubusercontent.com/initializ/forge/main/install.sh | bash

# Create and run an agent
forge init my-agent && cd my-agent && forge run
Expand Down
100 changes: 86 additions & 14 deletions docs/dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,19 @@ The main view discovers all agents in the workspace directory and shows their st
|---------|-------------|
| Agent discovery | Auto-scans workspace for `forge.yaml` files |
| Start / Stop | Start and stop agents with one click |
| Daemon processes | Agents run as background daemons via `forge serve` — they survive UI shutdown |
| Live status | Real-time state updates (stopped, starting, running, errored) |
| Passphrase unlock | Prompts for `FORGE_PASSPHRASE` when agents have encrypted secrets |
| Auto-rescan | Detects new agents after creation |
| Unified management | All agents (UI-started or CLI-started) get identical Start/Stop controls |

### Agent Lifecycle

The UI manages agents as daemon processes using `forge serve start` / `forge serve stop` under the hood. This means:

- **Agents survive UI shutdown** — closing the dashboard does not kill running agents.
- **Restart detection** — restarting the UI auto-discovers running agents via `.forge/serve.json` and TCP probing.
- **Unified view** — agents started from the CLI (`forge serve start`) and agents started from the UI appear identically. There is no distinction between "UI-managed" and "CLI-managed" agents.

## Interactive Chat

Expand Down Expand Up @@ -86,28 +96,90 @@ Browse the built-in skill registry with filtering and detail view:
| Detail panel | Click a skill to view its full SKILL.md content |
| Env requirements | Shows required, one-of, and optional env vars per skill |

## Skill Builder

An AI-powered conversational tool for creating custom skills. Access it via the **Build Skill** button on any agent card, or navigate to `#/skill-builder/{agent-id}`.

### How It Works

The Skill Builder uses the agent's own LLM provider to power a chat conversation that generates valid SKILL.md files and optional helper scripts. It automatically selects a stronger code-generation model when available (e.g. `gpt-4.1` for OpenAI, `claude-opus-4-6` for Anthropic).

### Features

| Feature | Description |
|---------|-------------|
| Conversational design | Describe what you want in plain language; the AI asks clarifying questions and generates the skill |
| Live streaming | LLM responses stream token-by-token via SSE |
| Artifact extraction | Automatically parses `skill.md` and `script:` code fences from the LLM response |
| SKILL.md preview | Live preview panel shows the generated SKILL.md with syntax highlighting |
| Script preview | View generated helper scripts alongside the SKILL.md |
| Validation | Server-side validation checks name format, required fields, egress domain declarations, and name uniqueness |
| One-click save | Save the validated skill directly to the agent's `skills/` directory |

### Workflow

1. **Open** the Skill Builder from an agent card
2. **Describe** the skill you want (e.g. "Create a skill that queries Jira issues")
3. **Iterate** — the AI asks about requirements, security constraints, and env vars
4. **Review** — inspect the generated SKILL.md and scripts in the preview panel
5. **Validate** — check for errors and warnings before saving
6. **Save** — writes `skills/{name}/SKILL.md` and `skills/{name}/scripts/` to the agent directory

### Validation Rules

The validator enforces the [SKILL.md format](skills.md):

| Check | Level | Description |
|-------|-------|-------------|
| Name present | Error | `name` is required in frontmatter |
| Name format | Error | Must be lowercase kebab-case, max 64 characters |
| Description present | Error | `description` is required in frontmatter |
| YAML parse | Error | Frontmatter must be valid YAML |
| Tool sections | Warning | Body should contain `## Tool:` sections |
| Category format | Warning | `category` should be lowercase kebab-case |
| Egress domains | Warning | Scripts referencing HTTP(S) URLs should declare them in `egress_domains` |
| Name uniqueness | Warning | Warns if a skill with the same name already exists in the agent |

### API Endpoints

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/agents/{id}/skill-builder/provider` | Returns the agent's LLM provider, codegen model, and API key status |
| `GET` | `/api/agents/{id}/skill-builder/context` | Returns the system prompt used for skill generation |
| `POST` | `/api/agents/{id}/skill-builder/chat` | Streams an LLM conversation via SSE (accepts `messages` array) |
| `POST` | `/api/agents/{id}/skill-builder/validate` | Validates a SKILL.md and optional scripts |
| `POST` | `/api/agents/{id}/skill-builder/save` | Saves a validated skill to `skills/{name}/` |

## Architecture

The dashboard is a single Go module (`forge-ui`) embedded into the `forge` binary:

```
forge-cli/cmd/ui.go CLI command, injects StartFunc/CreateFunc/OAuthFunc
forge-cli/cmd/ui.go CLI command, injects ExePath/CreateFunc/OAuthFunc/LLMStreamFunc
forge-ui/
server.go HTTP server with CORS, SPA fallback
handlers.go Dashboard API (agents, start/stop, chat, sessions)
handlers_create.go Wizard API (create, config, skills, tools, OAuth)
process.go Process manager (start/stop agent goroutines)
discovery.go Workspace scanner (finds forge.yaml files)
sse.go Server-Sent Events broker
chat.go A2A chat proxy with streaming
types.go Shared types
static/dist/ Embedded frontend (Preact + HTM, no build step)
app.js SPA with hash routing
style.css Dark theme styles
monaco/ Tree-shaken YAML editor
server.go HTTP server with CORS, SPA fallback
handlers.go Dashboard API (agents, start/stop, chat, sessions)
handlers_create.go Wizard API (create, config, skills, tools, OAuth)
handlers_skill_builder.go Skill Builder API (chat, validate, save, provider)
skill_builder_context.go System prompt for the Skill Designer AI
skill_validator.go SKILL.md validation and artifact extraction
process.go Process manager (exec forge serve start/stop)
discovery.go Workspace scanner (finds forge.yaml + detects running daemons)
sse.go Server-Sent Events broker
chat.go A2A chat proxy with streaming
types.go Shared types
static/dist/ Embedded frontend (Preact + HTM, no build step)
app.js SPA with hash routing
style.css Dark theme styles
monaco/ Tree-shaken YAML editor
```

Key design: `forge-cli` imports `forge-ui` (not vice versa). CLI-specific logic (scaffold, config loading, OAuth flow) is injected via function callbacks, keeping `forge-ui` framework-agnostic.
Key design decisions:

- **`forge-cli` imports `forge-ui`** (not vice versa). CLI-specific logic (scaffold, config loading, OAuth flow) is injected via function callbacks, keeping `forge-ui` framework-agnostic.
- **Daemon-based lifecycle** — the UI delegates to `forge serve start/stop` via `exec.Command`, so agents are independent OS processes that survive UI restarts.
- **Scanner as source of truth** — `discovery.go` reads `.forge/serve.json` and does a TCP probe to detect running agents. No in-memory state tracking is needed.
- **Version display** — the sidebar footer shows the Forge version (injected via `-ldflags` at build time) and links to [useforge.ai](https://useforge.ai).

---
← [Configuration](configuration.md) | [Back to README](../README.md) | [Deployment](deployment.md) →
7 changes: 4 additions & 3 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@ Forge can be installed via Homebrew, pre-built binary, or manual download on Win
brew install initializ/tap/forge
```

## Linux / macOS (Binary)
## Linux / macOS (Script)

Installs or upgrades Forge automatically:

```bash
curl -sSL https://github.com/initializ/forge/releases/latest/download/forge-$(uname -s)-$(uname -m).tar.gz | tar xz
sudo mv forge /usr/local/bin/
curl -sSL https://raw.githubusercontent.com/initializ/forge/main/install.sh | bash
```

## Windows
Expand Down
3 changes: 1 addition & 2 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,7 @@ No accidental exposure. No hidden listeners.

```bash
# Install
curl -sSL https://github.com/initializ/forge/releases/latest/download/forge-$(uname -s)-$(uname -m).tar.gz | tar xz
sudo mv forge /usr/local/bin/
curl -sSL https://raw.githubusercontent.com/initializ/forge/main/install.sh | bash

# Initialize a new agent (interactive wizard)
forge init my-agent
Expand Down
4 changes: 4 additions & 0 deletions docs/skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,5 +327,9 @@ forge init my-agent --from-skills
forge build
```

## Skill Builder (Web UI)

The [Web Dashboard](dashboard.md#skill-builder) includes an AI-powered Skill Builder that generates valid SKILL.md files and helper scripts through a conversational interface. It uses the agent's own LLM provider and includes server-side validation before saving to the agent's `skills/` directory.

---
← [Architecture](architecture.md) | [Back to README](../README.md) | [Tools](tools.md) →
187 changes: 136 additions & 51 deletions forge-cli/cmd/ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@ import (
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"

"github.com/initializ/forge/forge-cli/config"
"github.com/initializ/forge/forge-cli/internal/tui"
"github.com/initializ/forge/forge-cli/runtime"
"github.com/initializ/forge/forge-core/llm"
"github.com/initializ/forge/forge-core/llm/providers"
coreruntime "github.com/initializ/forge/forge-core/runtime"
"github.com/initializ/forge/forge-core/util"
"github.com/initializ/forge/forge-core/validate"
forgeui "github.com/initializ/forge/forge-ui"
"github.com/spf13/cobra"
)
Expand Down Expand Up @@ -52,49 +55,10 @@ func runUI(cmd *cobra.Command, args []string) error {
}
workDir = absDir

// Build the AgentStartFunc that wires into forge-cli's runtime.
startFunc := func(ctx context.Context, agentDir string, port int) error {
cfgPath := filepath.Join(agentDir, "forge.yaml")
cfg, err := config.LoadForgeConfig(cfgPath)
if err != nil {
return fmt.Errorf("loading config: %w", err)
}

result := validate.ValidateForgeConfig(cfg)
if !result.IsValid() {
for _, e := range result.Errors {
fmt.Fprintf(os.Stderr, "[%s] ERROR: %s\n", cfg.AgentID, e)
}
return fmt.Errorf("config validation failed: %d error(s)", len(result.Errors))
}

// Load .env
envPath := filepath.Join(agentDir, ".env")
envVars, err := runtime.LoadEnvFile(envPath)
if err != nil {
return fmt.Errorf("loading env: %w", err)
}
for k, v := range envVars {
if os.Getenv(k) == "" {
_ = os.Setenv(k, v)
}
}

// Overlay secrets
runtime.OverlaySecretsToEnv(cfg, agentDir)

runner, err := runtime.NewRunner(runtime.RunnerConfig{
Config: cfg,
WorkDir: agentDir,
Port: port,
EnvFilePath: envPath,
Verbose: verbose,
})
if err != nil {
return fmt.Errorf("creating runner: %w", err)
}

return runner.Run(ctx)
// Find forge executable path for daemon management.
exePath, err := os.Executable()
if err != nil {
return fmt.Errorf("finding forge executable: %w", err)
}

// Build the AgentCreateFunc that wraps scaffold() from init.go.
Expand Down Expand Up @@ -162,14 +126,135 @@ func runUI(cmd *cobra.Command, args []string) error {
return runOAuthFlow(provider)
}

// Build the LLMStreamFunc for skill builder conversations.
llmStreamFunc := func(ctx context.Context, opts forgeui.LLMStreamOptions) error {
// Load agent config
cfgPath := filepath.Join(opts.AgentDir, "forge.yaml")
cfg, err := config.LoadForgeConfig(cfgPath)
if err != nil {
return fmt.Errorf("loading config: %w", err)
}

// Load .env
envPath := filepath.Join(opts.AgentDir, ".env")
envVars, err := runtime.LoadEnvFile(envPath)
if err != nil {
return fmt.Errorf("loading env: %w", err)
}
for k, v := range envVars {
if os.Getenv(k) == "" {
_ = os.Setenv(k, v)
}
}

// Overlay encrypted secrets
runtime.OverlaySecretsToEnv(cfg, opts.AgentDir)

// Build env map for model resolution
envMap := make(map[string]string)
for k, v := range envVars {
envMap[k] = v
}
// Include OS env vars that may have been set by overlay
for _, kv := range os.Environ() {
parts := strings.SplitN(kv, "=", 2)
if len(parts) == 2 {
if _, exists := envMap[parts[0]]; !exists {
envMap[parts[0]] = parts[1]
}
}
}

mc := coreruntime.ResolveModelConfig(cfg, envMap, "")
if mc == nil {
return fmt.Errorf("unable to resolve model configuration")
}

mc.Client.Model = forgeui.SkillBuilderCodegenModel(mc.Provider, mc.Client.Model)

client, err := providers.NewClient(mc.Provider, mc.Client)
if err != nil {
return fmt.Errorf("creating LLM client: %w", err)
}

// Build chat request with system prompt + conversation messages
messages := []llm.ChatMessage{
{Role: "system", Content: opts.SystemPrompt},
}
for _, m := range opts.Messages {
messages = append(messages, llm.ChatMessage{
Role: m.Role,
Content: m.Content,
})
}

req := &llm.ChatRequest{
Model: mc.Client.Model,
Messages: messages,
Stream: true,
}

ch, err := client.ChatStream(ctx, req)
if err != nil {
return fmt.Errorf("starting LLM stream: %w", err)
}

var fullResponse strings.Builder
for delta := range ch {
if delta.Content != "" {
fullResponse.WriteString(delta.Content)
if opts.OnChunk != nil {
opts.OnChunk(delta.Content)
}
}
}

if opts.OnDone != nil {
opts.OnDone(fullResponse.String())
}

return nil
}

// Build the SkillSaveFunc for saving generated skills.
skillSaveFunc := func(opts forgeui.SkillSaveOptions) error {
skillDir := filepath.Join(opts.AgentDir, "skills", opts.SkillName)
if err := os.MkdirAll(skillDir, 0o755); err != nil {
return fmt.Errorf("creating skill directory: %w", err)
}

skillPath := filepath.Join(skillDir, "SKILL.md")
if err := os.WriteFile(skillPath, []byte(opts.SkillMD), 0o644); err != nil {
return fmt.Errorf("writing SKILL.md: %w", err)
}

if len(opts.Scripts) > 0 {
scriptsDir := filepath.Join(skillDir, "scripts")
if err := os.MkdirAll(scriptsDir, 0o755); err != nil {
return fmt.Errorf("creating scripts directory: %w", err)
}
for filename, content := range opts.Scripts {
scriptPath := filepath.Join(scriptsDir, filename)
if err := os.WriteFile(scriptPath, []byte(content), 0o755); err != nil {
return fmt.Errorf("writing script %s: %w", filename, err)
}
}
}

return nil
}

server := forgeui.NewUIServer(forgeui.UIServerConfig{
Port: uiPort,
WorkDir: workDir,
StartFunc: startFunc,
CreateFunc: createFunc,
OAuthFunc: oauthFunc,
AgentPort: 9100,
OpenBrowser: !uiNoOpen,
Port: uiPort,
WorkDir: workDir,
ExePath: exePath,
Version: appVersion,
CreateFunc: createFunc,
OAuthFunc: oauthFunc,
LLMStreamFunc: llmStreamFunc,
SkillSaveFunc: skillSaveFunc,
AgentPort: 9100,
OpenBrowser: !uiNoOpen,
})

// Signal handling
Expand Down
Loading
Loading