From 4acaccf8d73ce4d29c73b86969860dfcc04c1905 Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 4 Mar 2026 05:56:36 -0500 Subject: [PATCH 1/2] feat: skill builder UI, daemon lifecycle, install script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add AI-powered skill builder with LLM chat, validation, and save - Convert agent lifecycle from goroutines to forge serve daemon processes - Agents survive UI shutdown; scanner detects running daemons - Remove external/CLI agent distinction — unified start/stop for all - Add version display and useforge.ai link in sidebar footer - Add goreleaser commit hash injection via ldflags - Add install.sh for one-line install/upgrade - Update docs: dashboard, skills, installation, quickstart, README --- .goreleaser.yaml | 2 +- README.md | 5 +- docs/dashboard.md | 100 +++++- docs/installation.md | 7 +- docs/quickstart.md | 3 +- docs/skills.md | 4 + forge-cli/cmd/ui.go | 187 ++++++++--- forge-cli/runtime/runner.go | 1 + forge-cli/tools/exec.go | 4 + forge-ui/chat.go | 10 +- forge-ui/chat_test.go | 130 ++------ forge-ui/discovery.go | 46 +++ forge-ui/handlers.go | 35 +- forge-ui/handlers_create_test.go | 8 +- forge-ui/handlers_skill_builder.go | 249 ++++++++++++++ forge-ui/handlers_skill_builder_test.go | 416 ++++++++++++++++++++++++ forge-ui/handlers_test.go | 8 +- forge-ui/process.go | 182 +++-------- forge-ui/process_test.go | 79 ++--- forge-ui/server.go | 29 +- forge-ui/skill_builder_context.go | 373 +++++++++++++++++++++ forge-ui/skill_validator.go | 211 ++++++++++++ forge-ui/skill_validator_test.go | 299 +++++++++++++++++ forge-ui/static/dist/app.js | 414 ++++++++++++++++++++++- forge-ui/static/dist/style.css | 395 ++++++++++++++++++++++ forge-ui/types.go | 68 +++- install.sh | 85 +++++ 27 files changed, 2943 insertions(+), 407 deletions(-) create mode 100644 forge-ui/handlers_skill_builder.go create mode 100644 forge-ui/handlers_skill_builder_test.go create mode 100644 forge-ui/skill_builder_context.go create mode 100644 forge-ui/skill_validator.go create mode 100644 forge-ui/skill_validator_test.go create mode 100755 install.sh diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 24c2202..5a2a3a9 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -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: diff --git a/README.md b/README.md index e4129fa..cfce007 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/dashboard.md b/docs/dashboard.md index 01fd307..e6e0267 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -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 @@ -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) → diff --git a/docs/installation.md b/docs/installation.md index f193530..be6ba05 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -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 diff --git a/docs/quickstart.md b/docs/quickstart.md index 61d10be..aba64dd 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -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 diff --git a/docs/skills.md b/docs/skills.md index ca3de1f..f42b23c 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -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) → diff --git a/forge-cli/cmd/ui.go b/forge-cli/cmd/ui.go index a44186a..0610706 100644 --- a/forge-cli/cmd/ui.go +++ b/forge-cli/cmd/ui.go @@ -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" ) @@ -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. @@ -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 diff --git a/forge-cli/runtime/runner.go b/forge-cli/runtime/runner.go index a4410b2..3dde0b0 100644 --- a/forge-cli/runtime/runner.go +++ b/forge-cli/runtime/runner.go @@ -1645,6 +1645,7 @@ func (r *Runner) registerSkillTools(reg *tools.Registry, proxyURL string) { skillExec := &clitools.SkillCommandExecutor{ Timeout: timeout, + WorkDir: r.cfg.WorkDir, EnvVars: envVars, ProxyURL: proxyURL, } diff --git a/forge-cli/tools/exec.go b/forge-cli/tools/exec.go index ddc3431..94b9774 100644 --- a/forge-cli/tools/exec.go +++ b/forge-cli/tools/exec.go @@ -37,6 +37,7 @@ func (e *OSCommandExecutor) Run(ctx context.Context, command string, args []stri // timeout and environment variable passthrough for skill scripts. type SkillCommandExecutor struct { Timeout time.Duration + WorkDir string // agent working directory — script paths are relative to this EnvVars []string // extra env var names to pass through (e.g., "TAVILY_API_KEY") ProxyURL string // egress proxy URL (e.g., "http://127.0.0.1:54321") } @@ -51,6 +52,9 @@ func (e *SkillCommandExecutor) Run(ctx context.Context, command string, args []s cmd := exec.CommandContext(cmdCtx, command, args...) cmd.Stdin = bytes.NewReader(stdin) + if e.WorkDir != "" { + cmd.Dir = e.WorkDir + } // Build minimal environment with only explicitly allowed variables. env := []string{ diff --git a/forge-ui/chat.go b/forge-ui/chat.go index 122e187..f27751d 100644 --- a/forge-ui/chat.go +++ b/forge-ui/chat.go @@ -35,11 +35,17 @@ func (s *UIServer) handleChat(w http.ResponseWriter, r *http.Request) { return } - port, ok := s.pm.GetPort(agentID) - if !ok { + agents, err := s.scanner.Scan() + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + agent := agents[agentID] + if agent == nil || agent.Port == 0 { writeError(w, http.StatusBadRequest, "agent is not running") return } + port := agent.Port // Generate session ID if not provided. sessionID := req.SessionID diff --git a/forge-ui/chat_test.go b/forge-ui/chat_test.go index 3282253..3776aba 100644 --- a/forge-ui/chat_test.go +++ b/forge-ui/chat_test.go @@ -2,7 +2,6 @@ package forgeui import ( "bufio" - "context" "encoding/json" "fmt" "net/http" @@ -22,7 +21,7 @@ func newTestServer(t *testing.T) (*UIServer, string) { broker := NewSSEBroker() scanner := NewScanner(dir) - pm := NewProcessManager(nil, broker, 9100) + pm := NewProcessManager("/usr/bin/false", broker, 9100) s := &UIServer{ cfg: UIServerConfig{WorkDir: dir}, @@ -285,7 +284,6 @@ func TestHandleChatProxy(t *testing.T) { defer mockAgent.Close() // Extract the port from the mock server URL. - // The mock server URL is like http://127.0.0.1:PORT urlParts := strings.Split(mockAgent.URL, ":") portStr := urlParts[len(urlParts)-1] mockPort, err := strconv.Atoi(portStr) @@ -293,24 +291,39 @@ func TestHandleChatProxy(t *testing.T) { t.Fatalf("failed to parse mock server port: %v", err) } - // Create server with a process manager that knows about the mock agent. + // Create a workspace dir with an agent and a .forge/serve.json + // so detectExternalAgent() finds it as running. dir := t.TempDir() - broker := NewSSEBroker() - scanner := NewScanner(dir) - pm := NewProcessManager(nil, broker, 9100) + agentDir := filepath.Join(dir, "mock-agent") + if err := os.MkdirAll(filepath.Join(agentDir, ".forge"), 0o755); err != nil { + t.Fatal(err) + } - // Manually inject the mock agent into process manager. - pm.mu.Lock() - pm.agents["mock-agent"] = &managedAgent{ - cancel: func() {}, - port: mockPort, + // Write forge.yaml + config := `agent_id: mock-agent +version: 0.1.0 +framework: forge +model: + provider: openai + name: gpt-4o +` + if err := os.WriteFile(filepath.Join(agentDir, "forge.yaml"), []byte(config), 0o644); err != nil { + t.Fatal(err) } - pm.states["mock-agent"] = &AgentInfo{ - ID: "mock-agent", - Status: StateRunning, - Port: mockPort, + + // Write serve.json so the scanner detects the agent as running on mockPort. + serveState, _ := json.Marshal(map[string]any{ + "pid": os.Getpid(), + "port": mockPort, + "host": "127.0.0.1", + }) + if err := os.WriteFile(filepath.Join(agentDir, ".forge", "serve.json"), serveState, 0o644); err != nil { + t.Fatal(err) } - pm.mu.Unlock() + + broker := NewSSEBroker() + scanner := NewScanner(dir) + pm := NewProcessManager("/usr/bin/false", broker, 9100) s := &UIServer{ cfg: UIServerConfig{WorkDir: dir}, @@ -491,65 +504,6 @@ secrets: } } -func TestHandleStartAgentWithPassphrase(t *testing.T) { - s, dir := newTestServer(t) - agentDir := createTestAgent(t, dir, "secret-agent") - - // Add encrypted-file to secrets providers. - config := `agent_id: secret-agent -version: 0.1.0 -framework: forge -model: - provider: openai - name: gpt-4o -secrets: - providers: - - encrypted-file -` - if err := os.WriteFile(filepath.Join(agentDir, "forge.yaml"), []byte(config), 0o644); err != nil { - t.Fatal(err) - } - - secretsDir := filepath.Join(agentDir, ".forge") - if err := os.MkdirAll(secretsDir, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(secretsDir, "secrets.enc"), []byte("encrypted-data"), 0o600); err != nil { - t.Fatal(err) - } - - // Clear FORGE_PASSPHRASE. - t.Setenv("FORGE_PASSPHRASE", "") - _ = os.Unsetenv("FORGE_PASSPHRASE") - - // Provide a mock startFunc that just blocks until cancelled. - s.pm.startFunc = func(ctx context.Context, agentDir string, port int) error { - <-ctx.Done() - return nil - } - - // Start with passphrase in body — should succeed. - body := `{"passphrase":"my-secret"}` - req := httptest.NewRequest(http.MethodPost, "/api/agents/secret-agent/start", strings.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - req.SetPathValue("id", "secret-agent") - rec := httptest.NewRecorder() - - s.handleStartAgent(rec, req) - - if rec.Code != http.StatusAccepted { - t.Errorf("expected 202, got %d: %s", rec.Code, rec.Body.String()) - } - - // Verify FORGE_PASSPHRASE was set. - if got := os.Getenv("FORGE_PASSPHRASE"); got != "my-secret" { - t.Errorf("expected FORGE_PASSPHRASE='my-secret', got %q", got) - } - - // Cleanup: stop the agent. - _ = s.pm.Stop("secret-agent") -} - func TestNeedsPassphraseDetection(t *testing.T) { dir := t.TempDir() @@ -620,27 +574,3 @@ secrets: t.Error("expected NeedsPassphrase=true with local secrets.enc") } } - -func TestGetPort(t *testing.T) { - broker := NewSSEBroker() - pm := NewProcessManager(nil, broker, 9100) - - // Not running — should return false. - _, ok := pm.GetPort("nonexistent") - if ok { - t.Error("expected GetPort to return false for nonexistent agent") - } - - // Manually add a managed agent. - pm.mu.Lock() - pm.agents["test"] = &managedAgent{cancel: func() {}, port: 9200} - pm.mu.Unlock() - - port, ok := pm.GetPort("test") - if !ok { - t.Error("expected GetPort to return true for existing agent") - } - if port != 9200 { - t.Errorf("expected port 9200, got %d", port) - } -} diff --git a/forge-ui/discovery.go b/forge-ui/discovery.go index cd89f14..5a4692c 100644 --- a/forge-ui/discovery.go +++ b/forge-ui/discovery.go @@ -1,13 +1,24 @@ package forgeui import ( + "encoding/json" + "fmt" + "net" "os" "path/filepath" "strings" + "time" "github.com/initializ/forge/forge-core/types" ) +// externalDaemonState mirrors the daemonState written by `forge serve start`. +type externalDaemonState struct { + PID int `json:"pid"` + Port int `json:"port"` + Host string `json:"host"` +} + // Scanner discovers agents in a workspace directory. type Scanner struct { rootDir string @@ -90,9 +101,44 @@ func (s *Scanner) scanDir(dir string) (*AgentInfo, error) { NeedsPassphrase: needsPassphrase(cfg, dir), } + // Detect externally-running agents (started via `forge serve` or `forge run`). + if port, ok := detectExternalAgent(dir); ok { + info.Status = StateRunning + info.Port = port + } + return info, nil } +// detectExternalAgent checks whether an agent in dir is running externally +// (started via CLI rather than the UI). It reads .forge/serve.json and +// verifies the port is still listening. Returns the port and true if running. +func detectExternalAgent(dir string) (int, bool) { + statePath := filepath.Join(dir, ".forge", "serve.json") + data, err := os.ReadFile(statePath) + if err != nil { + return 0, false + } + + var state externalDaemonState + if err := json.Unmarshal(data, &state); err != nil || state.Port <= 0 { + return 0, false + } + + host := state.Host + if host == "" { + host = "127.0.0.1" + } + + // Verify the port is actually listening (fast TCP probe). + conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", host, state.Port), 500*time.Millisecond) + if err != nil { + return 0, false + } + _ = conn.Close() + return state.Port, true +} + // needsPassphrase returns true if the agent uses encrypted-file secrets // and a secrets.enc file exists. func needsPassphrase(cfg *types.ForgeConfig, dir string) bool { diff --git a/forge-ui/handlers.go b/forge-ui/handlers.go index 676f39f..64ed7f1 100644 --- a/forge-ui/handlers.go +++ b/forge-ui/handlers.go @@ -25,7 +25,6 @@ func (s *UIServer) handleListAgents(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, err.Error()) return } - s.pm.MergeState(agents) // Convert to sorted slice list := make([]*AgentInfo, 0, len(agents)) @@ -52,7 +51,6 @@ func (s *UIServer) handleGetAgent(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, err.Error()) return } - s.pm.MergeState(agents) agent, ok := agents[id] if !ok { @@ -89,16 +87,19 @@ func (s *UIServer) handleStartAgent(w http.ResponseWriter, r *http.Request) { return } - // If agent needs a passphrase and one was provided, set it in the environment - // so OverlaySecretsToEnv can decrypt secrets.enc. - if req.Passphrase != "" { - _ = os.Setenv("FORGE_PASSPHRASE", req.Passphrase) - } else if agent.NeedsPassphrase && os.Getenv("FORGE_PASSPHRASE") == "" { + // If agent needs a passphrase, require one. + if agent.NeedsPassphrase && req.Passphrase == "" && os.Getenv("FORGE_PASSPHRASE") == "" { writeError(w, http.StatusBadRequest, "passphrase required for encrypted secrets") return } - if err := s.pm.Start(id, agent); err != nil { + // Pass passphrase to the daemon process via env var. + passphrase := req.Passphrase + if passphrase == "" { + passphrase = os.Getenv("FORGE_PASSPHRASE") + } + + if err := s.pm.Start(id, agent, passphrase); err != nil { writeError(w, http.StatusConflict, err.Error()) return } @@ -114,12 +115,23 @@ func (s *UIServer) handleStopAgent(w http.ResponseWriter, r *http.Request) { return } - if err := s.pm.Stop(id); err != nil { + agents, err := s.scanner.Scan() + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + agent, ok := agents[id] + if !ok { + writeError(w, http.StatusNotFound, "agent not found") + return + } + + if err := s.pm.Stop(id, agent.Directory); err != nil { writeError(w, http.StatusConflict, err.Error()) return } - writeJSON(w, http.StatusOK, map[string]string{"status": "stopping", "agent_id": id}) + writeJSON(w, http.StatusOK, map[string]string{"status": "stopped", "agent_id": id}) } // handleRescan forces a workspace re-scan and returns the updated agent list. @@ -129,7 +141,6 @@ func (s *UIServer) handleRescan(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, err.Error()) return } - s.pm.MergeState(agents) list := make([]*AgentInfo, 0, len(agents)) for _, a := range agents { @@ -180,7 +191,6 @@ func (s *UIServer) handleSSE(w http.ResponseWriter, r *http.Request) { func (s *UIServer) handleHealth(w http.ResponseWriter, _ *http.Request) { running := 0 agents, _ := s.scanner.Scan() - s.pm.MergeState(agents) for _, a := range agents { if a.Status == StateRunning || a.Status == StateStarting { running++ @@ -189,5 +199,6 @@ func (s *UIServer) handleHealth(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]any{ "status": "ok", "agents_running": running, + "version": s.cfg.Version, }) } diff --git a/forge-ui/handlers_create_test.go b/forge-ui/handlers_create_test.go index 1a767c8..031c710 100644 --- a/forge-ui/handlers_create_test.go +++ b/forge-ui/handlers_create_test.go @@ -2,7 +2,6 @@ package forgeui import ( "bytes" - "context" "encoding/json" "net/http" "net/http/httptest" @@ -28,11 +27,6 @@ model: name: gpt-4o `) - mockStart := func(ctx context.Context, agentDir string, port int) error { - <-ctx.Done() - return nil - } - mockCreate := func(opts AgentCreateOptions) (string, error) { dir := filepath.Join(root, opts.Name) if err := os.MkdirAll(dir, 0o755); err != nil { @@ -51,7 +45,7 @@ model: srv := NewUIServer(UIServerConfig{ Port: 4200, WorkDir: root, - StartFunc: mockStart, + ExePath: "/usr/bin/false", CreateFunc: mockCreate, AgentPort: 9100, }) diff --git a/forge-ui/handlers_skill_builder.go b/forge-ui/handlers_skill_builder.go new file mode 100644 index 0000000..b1c9cc3 --- /dev/null +++ b/forge-ui/handlers_skill_builder.go @@ -0,0 +1,249 @@ +package forgeui + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/initializ/forge/forge-core/types" +) + +// SkillBuilderCodegenModel returns the preferred code-generation model for the +// given provider. Skill generation is a complex task that benefits from stronger +// models than the agent's default. Falls back to fallback if the provider is unknown. +func SkillBuilderCodegenModel(provider, fallback string) string { + switch provider { + case "openai": + return "gpt-4.1" + case "anthropic": + return "claude-opus-4-6" + default: + return fallback + } +} + +// resolveAgentDir extracts agent ID from the request, looks up the agent, +// and returns its directory. Writes an error response and returns "" on failure. +func (s *UIServer) resolveAgentDir(w http.ResponseWriter, r *http.Request) string { + id := r.PathValue("id") + if id == "" { + writeError(w, http.StatusBadRequest, "agent id is required") + return "" + } + + agents, err := s.scanner.Scan() + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return "" + } + + agent, ok := agents[id] + if !ok { + writeError(w, http.StatusNotFound, "agent not found") + return "" + } + + return agent.Directory +} + +// handleSkillBuilderProvider returns the agent's LLM provider info. +func (s *UIServer) handleSkillBuilderProvider(w http.ResponseWriter, r *http.Request) { + agentDir := s.resolveAgentDir(w, r) + if agentDir == "" { + return + } + + configPath := filepath.Join(agentDir, "forge.yaml") + data, err := os.ReadFile(configPath) + if err != nil { + writeError(w, http.StatusInternalServerError, "reading config: "+err.Error()) + return + } + + cfg, err := types.ParseForgeConfig(data) + if err != nil { + writeError(w, http.StatusInternalServerError, "parsing config: "+err.Error()) + return + } + + provider := cfg.Model.Provider + model := SkillBuilderCodegenModel(provider, cfg.Model.Name) + + // Check if the provider's API key env var is set + hasKey := false + switch provider { + case "openai": + hasKey = os.Getenv("OPENAI_API_KEY") != "" + case "anthropic": + hasKey = os.Getenv("ANTHROPIC_API_KEY") != "" + case "gemini": + hasKey = os.Getenv("GEMINI_API_KEY") != "" + case "ollama": + hasKey = true // Ollama doesn't need an API key + default: + hasKey = os.Getenv("LLM_API_KEY") != "" || os.Getenv("MODEL_API_KEY") != "" + } + + writeJSON(w, http.StatusOK, map[string]any{ + "provider": provider, + "model": model, + "has_key": hasKey, + }) +} + +// handleSkillBuilderContext returns the system prompt for the skill builder. +func (s *UIServer) handleSkillBuilderContext(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{ + "system_prompt": skillBuilderSystemPrompt, + }) +} + +// handleSkillBuilderChat streams an LLM conversation for skill building via SSE. +func (s *UIServer) handleSkillBuilderChat(w http.ResponseWriter, r *http.Request) { + if s.cfg.LLMStreamFunc == nil { + writeError(w, http.StatusNotImplemented, "skill builder LLM streaming not available") + return + } + + agentDir := s.resolveAgentDir(w, r) + if agentDir == "" { + return + } + + var req SkillBuilderChatRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + if len(req.Messages) == 0 { + writeError(w, http.StatusBadRequest, "messages are required") + return + } + + flusher, ok := w.(http.Flusher) + if !ok { + writeError(w, http.StatusInternalServerError, "streaming not supported") + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + flusher.Flush() + + var fullResponse strings.Builder + + err := s.cfg.LLMStreamFunc(r.Context(), LLMStreamOptions{ + AgentDir: agentDir, + SystemPrompt: skillBuilderSystemPrompt, + Messages: req.Messages, + OnChunk: func(chunk string) { + fullResponse.WriteString(chunk) + data, _ := json.Marshal(map[string]string{"content": chunk}) + _, _ = fmt.Fprintf(w, "event: chunk\ndata: %s\n\n", data) + flusher.Flush() + }, + OnDone: func(response string) { + // Extract artifacts from the full response + skillMD, scripts := extractArtifacts(response) + if skillMD != "" { + draftData, _ := json.Marshal(map[string]any{ + "skill_md": skillMD, + "scripts": scripts, + }) + _, _ = fmt.Fprintf(w, "event: skill_draft\ndata: %s\n\n", draftData) + flusher.Flush() + } + + doneData, _ := json.Marshal(map[string]string{"status": "complete"}) + _, _ = fmt.Fprintf(w, "event: done\ndata: %s\n\n", doneData) + flusher.Flush() + }, + }) + + if err != nil { + errData, _ := json.Marshal(map[string]string{"error": err.Error()}) + _, _ = fmt.Fprintf(w, "event: error\ndata: %s\n\n", errData) + flusher.Flush() + } +} + +// handleSkillBuilderValidate validates a SKILL.md and optional scripts. +func (s *UIServer) handleSkillBuilderValidate(w http.ResponseWriter, r *http.Request) { + agentDir := s.resolveAgentDir(w, r) + if agentDir == "" { + return + } + + var req SkillBuilderValidateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + result := validateSkillMD(req.SkillMD, req.Scripts, agentDir) + writeJSON(w, http.StatusOK, result) +} + +// handleSkillBuilderSave saves a validated skill to the agent's skills directory. +func (s *UIServer) handleSkillBuilderSave(w http.ResponseWriter, r *http.Request) { + if s.cfg.SkillSaveFunc == nil { + writeError(w, http.StatusNotImplemented, "skill saving not available") + return + } + + agentDir := s.resolveAgentDir(w, r) + if agentDir == "" { + return + } + + var req SkillBuilderSaveRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + // Validate skill name format (security: prevent path traversal) + if req.SkillName == "" { + writeError(w, http.StatusBadRequest, "skill_name is required") + return + } + if !skillNamePattern.MatchString(req.SkillName) { + writeError(w, http.StatusBadRequest, "skill_name must be lowercase kebab-case") + return + } + if strings.Contains(req.SkillName, "/") || strings.Contains(req.SkillName, "\\") || strings.Contains(req.SkillName, "..") { + writeError(w, http.StatusBadRequest, "skill_name contains invalid characters") + return + } + + // Validate content first + result := validateSkillMD(req.SkillMD, req.Scripts, agentDir) + if !result.Valid { + writeJSON(w, http.StatusBadRequest, map[string]any{ + "error": "validation failed", + "validation": result, + }) + return + } + + err := s.cfg.SkillSaveFunc(SkillSaveOptions{ + AgentDir: agentDir, + SkillName: req.SkillName, + SkillMD: req.SkillMD, + Scripts: req.Scripts, + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "saving skill: "+err.Error()) + return + } + + writeJSON(w, http.StatusOK, map[string]string{ + "status": "saved", + "path": "skills/" + req.SkillName + "/SKILL.md", + }) +} diff --git a/forge-ui/handlers_skill_builder_test.go b/forge-ui/handlers_skill_builder_test.go new file mode 100644 index 0000000..2b2bab2 --- /dev/null +++ b/forge-ui/handlers_skill_builder_test.go @@ -0,0 +1,416 @@ +package forgeui + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func setupTestServerWithSkillBuilder(t *testing.T) (*UIServer, string) { + t.Helper() + root := t.TempDir() + + // Create test agent + agentDir := filepath.Join(root, "test-agent") + if err := os.MkdirAll(agentDir, 0o755); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(agentDir, "forge.yaml"), `agent_id: test-agent +version: 0.1.0 +framework: forge +model: + provider: openai + name: gpt-4o +`) + + mockStream := func(ctx context.Context, opts LLMStreamOptions) error { + response := "Here is your skill:\n```skill.md\n---\nname: test-skill\ndescription: A test skill\n---\n\n# Test Skill\n\n## Tool: test_tool\n\nA test tool.\n```\n" + for _, ch := range response { + opts.OnChunk(string(ch)) + } + opts.OnDone(response) + return nil + } + + mockSave := func(opts SkillSaveOptions) error { + skillDir := filepath.Join(opts.AgentDir, "skills", opts.SkillName) + if err := os.MkdirAll(skillDir, 0o755); err != nil { + return err + } + return os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(opts.SkillMD), 0o644) + } + + srv := NewUIServer(UIServerConfig{ + Port: 4200, + WorkDir: root, + ExePath: "/usr/bin/false", + LLMStreamFunc: mockStream, + SkillSaveFunc: mockSave, + AgentPort: 9100, + }) + + return srv, root +} + +func TestSkillBuilderProvider(t *testing.T) { + srv, _ := setupTestServerWithSkillBuilder(t) + + req := httptest.NewRequest(http.MethodGet, "/api/agents/test-agent/skill-builder/provider", nil) + req.SetPathValue("id", "test-agent") + w := httptest.NewRecorder() + srv.handleSkillBuilderProvider(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) + } + + var resp map[string]any + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode error: %v", err) + } + + if resp["provider"] != "openai" { + t.Errorf("provider = %q, want %q", resp["provider"], "openai") + } + if resp["model"] != "gpt-4.1" { + t.Errorf("model = %q, want %q", resp["model"], "gpt-4.1") + } +} + +func TestSkillBuilderProviderAnthropicOverride(t *testing.T) { + root := t.TempDir() + + agentDir := filepath.Join(root, "anthropic-agent") + if err := os.MkdirAll(agentDir, 0o755); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(agentDir, "forge.yaml"), `agent_id: anthropic-agent +version: 0.1.0 +framework: forge +model: + provider: anthropic + name: claude-sonnet-4-20250514 +`) + + srv := NewUIServer(UIServerConfig{ + Port: 4200, + WorkDir: root, + AgentPort: 9100, + }) + + req := httptest.NewRequest(http.MethodGet, "/api/agents/anthropic-agent/skill-builder/provider", nil) + req.SetPathValue("id", "anthropic-agent") + w := httptest.NewRecorder() + srv.handleSkillBuilderProvider(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) + } + + var resp map[string]any + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode error: %v", err) + } + + if resp["provider"] != "anthropic" { + t.Errorf("provider = %q, want %q", resp["provider"], "anthropic") + } + if resp["model"] != "claude-opus-4-6" { + t.Errorf("model = %q, want %q", resp["model"], "claude-opus-4-6") + } +} + +func TestSkillBuilderContext(t *testing.T) { + srv, _ := setupTestServerWithSkillBuilder(t) + + req := httptest.NewRequest(http.MethodGet, "/api/agents/test-agent/skill-builder/context", nil) + w := httptest.NewRecorder() + srv.handleSkillBuilderContext(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", w.Code, http.StatusOK) + } + + var resp map[string]string + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode error: %v", err) + } + + if resp["system_prompt"] == "" { + t.Error("expected non-empty system_prompt") + } +} + +func TestSkillBuilderChatNoFunc(t *testing.T) { + root := t.TempDir() + + agentDir := filepath.Join(root, "test-agent") + if err := os.MkdirAll(agentDir, 0o755); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(agentDir, "forge.yaml"), `agent_id: test-agent +version: 0.1.0 +framework: forge +model: + provider: openai + name: gpt-4o +`) + + srv := NewUIServer(UIServerConfig{ + Port: 4200, + WorkDir: root, + AgentPort: 9100, + // No LLMStreamFunc + }) + + body, _ := json.Marshal(SkillBuilderChatRequest{ + Messages: []SkillBuilderMessage{{Role: "user", Content: "hello"}}, + }) + + req := httptest.NewRequest(http.MethodPost, "/api/agents/test-agent/skill-builder/chat", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.SetPathValue("id", "test-agent") + w := httptest.NewRecorder() + srv.handleSkillBuilderChat(w, req) + + if w.Code != http.StatusNotImplemented { + t.Fatalf("status = %d, want %d", w.Code, http.StatusNotImplemented) + } +} + +func TestSkillBuilderChatMissingAgent(t *testing.T) { + srv, _ := setupTestServerWithSkillBuilder(t) + + body, _ := json.Marshal(SkillBuilderChatRequest{ + Messages: []SkillBuilderMessage{{Role: "user", Content: "hello"}}, + }) + + req := httptest.NewRequest(http.MethodPost, "/api/agents/nonexistent/skill-builder/chat", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.SetPathValue("id", "nonexistent") + w := httptest.NewRecorder() + srv.handleSkillBuilderChat(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusNotFound, w.Body.String()) + } +} + +func TestSkillBuilderValidateValid(t *testing.T) { + srv, _ := setupTestServerWithSkillBuilder(t) + + validSkill := `--- +name: test-skill +description: A test skill +--- + +# Test Skill + +## Tool: test_tool + +A test tool. +` + body, _ := json.Marshal(SkillBuilderValidateRequest{SkillMD: validSkill}) + + req := httptest.NewRequest(http.MethodPost, "/api/agents/test-agent/skill-builder/validate", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.SetPathValue("id", "test-agent") + w := httptest.NewRecorder() + srv.handleSkillBuilderValidate(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) + } + + var result SkillValidationResult + if err := json.NewDecoder(w.Body).Decode(&result); err != nil { + t.Fatalf("decode error: %v", err) + } + + if !result.Valid { + t.Errorf("expected valid, got errors: %v", result.Errors) + } +} + +func TestSkillBuilderValidateMissingName(t *testing.T) { + srv, _ := setupTestServerWithSkillBuilder(t) + + invalidSkill := `--- +description: A test skill +--- + +# Test Skill +` + body, _ := json.Marshal(SkillBuilderValidateRequest{SkillMD: invalidSkill}) + + req := httptest.NewRequest(http.MethodPost, "/api/agents/test-agent/skill-builder/validate", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.SetPathValue("id", "test-agent") + w := httptest.NewRecorder() + srv.handleSkillBuilderValidate(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", w.Code, http.StatusOK) + } + + var result SkillValidationResult + if err := json.NewDecoder(w.Body).Decode(&result); err != nil { + t.Fatalf("decode error: %v", err) + } + + if result.Valid { + t.Error("expected invalid result for missing name") + } + + hasNameError := false + for _, e := range result.Errors { + if e.Field == "name" { + hasNameError = true + break + } + } + if !hasNameError { + t.Error("expected error for field 'name'") + } +} + +func TestSkillBuilderValidateInvalidYAML(t *testing.T) { + srv, _ := setupTestServerWithSkillBuilder(t) + + invalidSkill := `--- +name: [invalid yaml +--- +` + body, _ := json.Marshal(SkillBuilderValidateRequest{SkillMD: invalidSkill}) + + req := httptest.NewRequest(http.MethodPost, "/api/agents/test-agent/skill-builder/validate", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.SetPathValue("id", "test-agent") + w := httptest.NewRecorder() + srv.handleSkillBuilderValidate(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", w.Code, http.StatusOK) + } + + var result SkillValidationResult + if err := json.NewDecoder(w.Body).Decode(&result); err != nil { + t.Fatalf("decode error: %v", err) + } + + if result.Valid { + t.Error("expected invalid result for bad YAML") + } +} + +func TestSkillBuilderSaveSuccess(t *testing.T) { + srv, root := setupTestServerWithSkillBuilder(t) + + validSkill := `--- +name: new-skill +description: A new skill +--- + +# New Skill + +## Tool: new_tool + +A new tool. +` + body, _ := json.Marshal(SkillBuilderSaveRequest{ + SkillName: "new-skill", + SkillMD: validSkill, + }) + + req := httptest.NewRequest(http.MethodPost, "/api/agents/test-agent/skill-builder/save", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.SetPathValue("id", "test-agent") + w := httptest.NewRecorder() + srv.handleSkillBuilderSave(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) + } + + var resp map[string]string + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode error: %v", err) + } + + if resp["status"] != "saved" { + t.Errorf("status = %q, want %q", resp["status"], "saved") + } + + // Verify file was created + skillPath := filepath.Join(root, "test-agent", "skills", "new-skill", "SKILL.md") + data, err := os.ReadFile(skillPath) + if err != nil { + t.Fatalf("reading saved skill: %v", err) + } + if string(data) != validSkill { + t.Errorf("saved content mismatch:\ngot: %q\nwant: %q", string(data), validSkill) + } +} + +func TestSkillBuilderSaveNoFunc(t *testing.T) { + root := t.TempDir() + + agentDir := filepath.Join(root, "test-agent") + if err := os.MkdirAll(agentDir, 0o755); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(agentDir, "forge.yaml"), `agent_id: test-agent +version: 0.1.0 +framework: forge +model: + provider: openai + name: gpt-4o +`) + + srv := NewUIServer(UIServerConfig{ + Port: 4200, + WorkDir: root, + AgentPort: 9100, + // No SkillSaveFunc + }) + + body, _ := json.Marshal(SkillBuilderSaveRequest{ + SkillName: "test", + SkillMD: "---\nname: test\ndescription: test\n---\n# Test\n## Tool: t\nA tool.\n", + }) + + req := httptest.NewRequest(http.MethodPost, "/api/agents/test-agent/skill-builder/save", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.SetPathValue("id", "test-agent") + w := httptest.NewRecorder() + srv.handleSkillBuilderSave(w, req) + + if w.Code != http.StatusNotImplemented { + t.Fatalf("status = %d, want %d", w.Code, http.StatusNotImplemented) + } +} + +func TestSkillBuilderSaveValidationFirst(t *testing.T) { + srv, _ := setupTestServerWithSkillBuilder(t) + + // Invalid content (no name) + body, _ := json.Marshal(SkillBuilderSaveRequest{ + SkillName: "bad-skill", + SkillMD: "---\ndescription: test\n---\n", + }) + + req := httptest.NewRequest(http.MethodPost, "/api/agents/test-agent/skill-builder/save", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.SetPathValue("id", "test-agent") + w := httptest.NewRecorder() + srv.handleSkillBuilderSave(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusBadRequest, w.Body.String()) + } +} diff --git a/forge-ui/handlers_test.go b/forge-ui/handlers_test.go index 09ebad2..101502c 100644 --- a/forge-ui/handlers_test.go +++ b/forge-ui/handlers_test.go @@ -1,7 +1,6 @@ package forgeui import ( - "context" "encoding/json" "net/http" "net/http/httptest" @@ -28,15 +27,10 @@ model: name: gpt-4o `) - mockStart := func(ctx context.Context, agentDir string, port int) error { - <-ctx.Done() - return nil - } - srv := NewUIServer(UIServerConfig{ Port: 4200, WorkDir: root, - StartFunc: mockStart, + ExePath: "/usr/bin/false", AgentPort: 9100, }) diff --git a/forge-ui/process.go b/forge-ui/process.go index c5f5d0a..28d78b9 100644 --- a/forge-ui/process.go +++ b/forge-ui/process.go @@ -1,10 +1,10 @@ package forgeui import ( - "context" "fmt" + "os/exec" + "strconv" "sync" - "time" ) // PortAllocator manages port assignment for agent processes. @@ -43,167 +43,87 @@ func (pa *PortAllocator) Release(port int) { delete(pa.used, port) } -// managedAgent tracks a running agent's context and cancel func. -type managedAgent struct { - cancel context.CancelFunc - port int -} - -// ProcessManager manages agent process lifecycles. +// ProcessManager manages agent process lifecycles via `forge serve` commands. type ProcessManager struct { - mu sync.RWMutex - startFunc AgentStartFunc - agents map[string]*managedAgent - states map[string]*AgentInfo - ports *PortAllocator - broker *SSEBroker + mu sync.Mutex + exePath string + ports *PortAllocator + broker *SSEBroker + // allocated tracks which ports were allocated by this PM so we can release them. + allocated map[string]int } // NewProcessManager creates a ProcessManager. -func NewProcessManager(startFunc AgentStartFunc, broker *SSEBroker, basePort int) *ProcessManager { +func NewProcessManager(exePath string, broker *SSEBroker, basePort int) *ProcessManager { return &ProcessManager{ - startFunc: startFunc, - agents: make(map[string]*managedAgent), - states: make(map[string]*AgentInfo), + exePath: exePath, ports: NewPortAllocator(basePort), broker: broker, + allocated: make(map[string]int), } } -// Start launches an agent. Returns an error if the agent is already running. -func (pm *ProcessManager) Start(agentID string, info *AgentInfo) error { +// Start launches an agent via `forge serve start`. +func (pm *ProcessManager) Start(agentID string, info *AgentInfo, passphrase string) error { pm.mu.Lock() - if _, ok := pm.agents[agentID]; ok { - pm.mu.Unlock() - return fmt.Errorf("agent %s is already running", agentID) - } + defer pm.mu.Unlock() port := pm.ports.Allocate() - ctx, cancel := context.WithCancel(context.Background()) + pm.allocated[agentID] = port - pm.agents[agentID] = &managedAgent{cancel: cancel, port: port} + cmd := exec.Command(pm.exePath, "serve", "start", "--port", strconv.Itoa(port), "--no-auth") + cmd.Dir = info.Directory - // Update state - now := time.Now() - info.Status = StateStarting - info.Port = port - info.Error = "" - info.StartedAt = &now - pm.states[agentID] = info - pm.mu.Unlock() - - pm.broker.Broadcast(SSEEvent{Type: "agent_status", Data: info}) + if passphrase != "" { + cmd.Env = append(cmd.Environ(), "FORGE_PASSPHRASE="+passphrase) + } - // Launch in goroutine — startFunc blocks until agent exits - go func() { - // Brief delay to allow status propagation, then set running - time.Sleep(500 * time.Millisecond) - pm.mu.Lock() - if s, ok := pm.states[agentID]; ok && s.Status == StateStarting { - s.Status = StateRunning - pm.broker.Broadcast(SSEEvent{Type: "agent_status", Data: s}) - } - pm.mu.Unlock() + if err := cmd.Run(); err != nil { + pm.ports.Release(port) + delete(pm.allocated, agentID) - err := pm.startFunc(ctx, info.Directory, port) + info.Status = StateErrored + info.Error = err.Error() + pm.broker.Broadcast(SSEEvent{Type: "agent_status", Data: info}) - pm.mu.Lock() - delete(pm.agents, agentID) - pm.ports.Release(port) + return fmt.Errorf("forge serve start failed: %w", err) + } - if s, ok := pm.states[agentID]; ok { - s.Port = 0 - s.StartedAt = nil - if err != nil && ctx.Err() == nil { - // Agent exited with error (not from cancellation) - s.Status = StateErrored - s.Error = err.Error() - } else { - s.Status = StateStopped - s.Error = "" - } - pm.broker.Broadcast(SSEEvent{Type: "agent_status", Data: s}) - } - pm.mu.Unlock() - }() + info.Status = StateRunning + info.Port = port + info.Error = "" + pm.broker.Broadcast(SSEEvent{Type: "agent_status", Data: info}) return nil } -// Stop signals an agent to stop. -func (pm *ProcessManager) Stop(agentID string) error { +// Stop stops an agent via `forge serve stop`. +func (pm *ProcessManager) Stop(agentID string, agentDir string) error { pm.mu.Lock() - managed, ok := pm.agents[agentID] - if !ok { - pm.mu.Unlock() - return fmt.Errorf("agent %s is not running", agentID) - } + defer pm.mu.Unlock() - if s, ok := pm.states[agentID]; ok { - s.Status = StateStopping - pm.broker.Broadcast(SSEEvent{Type: "agent_status", Data: s}) - } - pm.mu.Unlock() + cmd := exec.Command(pm.exePath, "serve", "stop") + cmd.Dir = agentDir - managed.cancel() - return nil -} - -// GetPort returns the port of a running agent. -func (pm *ProcessManager) GetPort(agentID string) (int, bool) { - pm.mu.RLock() - defer pm.mu.RUnlock() - if a, ok := pm.agents[agentID]; ok { - return a.port, true + if err := cmd.Run(); err != nil { + return fmt.Errorf("forge serve stop failed: %w", err) } - return 0, false -} -// Status returns the current state of an agent. -func (pm *ProcessManager) Status(agentID string) ProcessState { - pm.mu.RLock() - defer pm.mu.RUnlock() - if s, ok := pm.states[agentID]; ok { - return s.Status + if port, ok := pm.allocated[agentID]; ok { + pm.ports.Release(port) + delete(pm.allocated, agentID) } - return StateStopped -} -// GetState returns a copy of the agent's state info, or nil. -func (pm *ProcessManager) GetState(agentID string) *AgentInfo { - pm.mu.RLock() - defer pm.mu.RUnlock() - if s, ok := pm.states[agentID]; ok { - cp := *s - return &cp - } - return nil -} + pm.broker.Broadcast(SSEEvent{Type: "agent_status", Data: &AgentInfo{ + ID: agentID, + Directory: agentDir, + Status: StateStopped, + }}) -// MergeState merges process manager state into a discovered agent map. -func (pm *ProcessManager) MergeState(agents map[string]*AgentInfo) { - pm.mu.RLock() - defer pm.mu.RUnlock() - for id, state := range pm.states { - if agent, ok := agents[id]; ok { - agent.Status = state.Status - agent.Port = state.Port - agent.Error = state.Error - agent.StartedAt = state.StartedAt - } - } + return nil } -// StopAll stops all running agents. +// StopAll is a no-op — agents intentionally survive UI shutdown. func (pm *ProcessManager) StopAll() { - pm.mu.Lock() - agents := make(map[string]*managedAgent, len(pm.agents)) - for id, a := range pm.agents { - agents[id] = a - } - pm.mu.Unlock() - - for _, a := range agents { - a.cancel() - } + // Agents are daemon processes that survive UI shutdown. } diff --git a/forge-ui/process_test.go b/forge-ui/process_test.go index d0f8eb8..aea2951 100644 --- a/forge-ui/process_test.go +++ b/forge-ui/process_test.go @@ -1,9 +1,7 @@ package forgeui import ( - "context" "testing" - "time" ) func TestPortAllocator(t *testing.T) { @@ -26,77 +24,48 @@ func TestPortAllocator(t *testing.T) { } } -func TestProcessManagerStartStop(t *testing.T) { +func TestProcessManagerStartExecError(t *testing.T) { broker := NewSSEBroker() ch := broker.Subscribe() defer broker.Unsubscribe(ch) - started := make(chan struct{}) - stopped := make(chan struct{}) - - mockStart := func(ctx context.Context, agentDir string, port int) error { - close(started) - <-ctx.Done() - close(stopped) - return nil - } - - pm := NewProcessManager(mockStart, broker, 9100) + // Use a non-existent binary so the exec.Command fails. + pm := NewProcessManager("/nonexistent/binary", broker, 9100) info := &AgentInfo{ ID: "test-agent", - Directory: "/tmp/test", + Directory: t.TempDir(), Status: StateStopped, } - if err := pm.Start("test-agent", info); err != nil { - t.Fatalf("Start() error: %v", err) - } - - // Wait for agent to start - select { - case <-started: - case <-time.After(2 * time.Second): - t.Fatal("agent did not start in time") - } - - // Verify running state - status := pm.Status("test-agent") - if status != StateStarting && status != StateRunning { - t.Errorf("status = %q, want starting or running", status) + err := pm.Start("test-agent", info, "") + if err == nil { + t.Fatal("expected error from non-existent binary") } - // Should error on double start - info2 := &AgentInfo{ID: "test-agent", Directory: "/tmp/test"} - if err := pm.Start("test-agent", info2); err == nil { - t.Error("expected error on double start") - } - - // Stop - if err := pm.Stop("test-agent"); err != nil { - t.Fatalf("Stop() error: %v", err) - } - - select { - case <-stopped: - case <-time.After(2 * time.Second): - t.Fatal("agent did not stop in time") + // Port should have been released. + pm.ports.mu.Lock() + _, used := pm.ports.used[9100] + pm.ports.mu.Unlock() + if used { + t.Error("expected port 9100 to be released after failure") } +} - // Wait for state to settle - time.Sleep(100 * time.Millisecond) +func TestProcessManagerStopExecError(t *testing.T) { + broker := NewSSEBroker() + pm := NewProcessManager("/nonexistent/binary", broker, 9100) - status = pm.Status("test-agent") - if status != StateStopped { - t.Errorf("after stop, status = %q, want stopped", status) + err := pm.Stop("nonexistent", t.TempDir()) + if err == nil { + t.Error("expected error stopping with non-existent binary") } } -func TestProcessManagerStopNotRunning(t *testing.T) { +func TestProcessManagerStopAll(t *testing.T) { broker := NewSSEBroker() - pm := NewProcessManager(nil, broker, 9100) + pm := NewProcessManager("/usr/bin/false", broker, 9100) - if err := pm.Stop("nonexistent"); err == nil { - t.Error("expected error stopping non-running agent") - } + // StopAll is a no-op — should not panic. + pm.StopAll() } diff --git a/forge-ui/server.go b/forge-ui/server.go index db2c411..3fd0d3e 100644 --- a/forge-ui/server.go +++ b/forge-ui/server.go @@ -17,13 +17,16 @@ import ( // UIServerConfig configures the UI dashboard server. type UIServerConfig struct { - Port int // default: 4200 - WorkDir string // workspace root to scan for agents - StartFunc AgentStartFunc // injected by forge-cli - CreateFunc AgentCreateFunc // injected by forge-cli (Phase 3) - OAuthFunc OAuthFlowFunc // injected by forge-cli (optional, for OAuth login) - AgentPort int // base port for agent allocation (default: 9100) - OpenBrowser bool // open browser on start + Port int // default: 4200 + WorkDir string // workspace root to scan for agents + ExePath string // path to forge binary for exec + Version string // forge version string + CreateFunc AgentCreateFunc // injected by forge-cli (Phase 3) + OAuthFunc OAuthFlowFunc // injected by forge-cli (optional, for OAuth login) + LLMStreamFunc LLMStreamFunc // injected by forge-cli (skill builder) + SkillSaveFunc SkillSaveFunc // injected by forge-cli (skill builder) + AgentPort int // base port for agent allocation (default: 9100) + OpenBrowser bool // open browser on start } // UIServer serves the Forge dashboard UI and API. @@ -46,7 +49,7 @@ func NewUIServer(cfg UIServerConfig) *UIServer { broker := NewSSEBroker() scanner := NewScanner(cfg.WorkDir) - pm := NewProcessManager(cfg.StartFunc, broker, cfg.AgentPort) + pm := NewProcessManager(cfg.ExePath, broker, cfg.AgentPort) return &UIServer{ cfg: cfg, @@ -83,6 +86,13 @@ func (s *UIServer) Start(ctx context.Context) error { mux.HandleFunc("GET /api/tools", s.handleListBuiltinTools) mux.HandleFunc("POST /api/oauth/start", s.handleOAuthStart) + // Skill Builder routes + mux.HandleFunc("POST /api/agents/{id}/skill-builder/chat", s.handleSkillBuilderChat) + mux.HandleFunc("POST /api/agents/{id}/skill-builder/validate", s.handleSkillBuilderValidate) + mux.HandleFunc("POST /api/agents/{id}/skill-builder/save", s.handleSkillBuilderSave) + mux.HandleFunc("GET /api/agents/{id}/skill-builder/context", s.handleSkillBuilderContext) + mux.HandleFunc("GET /api/agents/{id}/skill-builder/provider", s.handleSkillBuilderProvider) + // Static file serving with SPA fallback distFS, err := fs.Sub(static.FS, "dist") if err != nil { @@ -137,10 +147,9 @@ func (s *UIServer) Start(ctx context.Context) error { }() } - // Graceful shutdown + // Graceful shutdown (agents survive UI shutdown) go func() { <-ctx.Done() - s.pm.StopAll() shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := s.srv.Shutdown(shutdownCtx); err != nil { diff --git a/forge-ui/skill_builder_context.go b/forge-ui/skill_builder_context.go new file mode 100644 index 0000000..bbfa00c --- /dev/null +++ b/forge-ui/skill_builder_context.go @@ -0,0 +1,373 @@ +package forgeui + +// skillBuilderSystemPrompt is the system prompt for the Forge Skill Designer AI assistant. +const skillBuilderSystemPrompt = `You are the Forge Skill Designer, an expert assistant that helps users create valid SKILL.md files for Forge agents. + +## Your Role + +You help users design skills by: +1. Understanding what they want the skill to do +2. Asking clarifying questions about requirements, security, and integration +3. Generating a complete, valid SKILL.md file +4. Optionally generating helper scripts if the skill requires them + +## SKILL.md Format + +A SKILL.md file has two parts: + +### 1. YAML Frontmatter (between --- delimiters) + +` + "```" + `yaml +--- +name: my-skill-name # Required: lowercase kebab-case, max 64 chars +category: ops # Optional: sre, research, ops, developer, security, etc. +tags: # Optional: discovery keywords (lowercase kebab-case) + - example + - automation +description: One-line description # Required: what this skill does +metadata: + forge: + requires: + bins: # Binaries that must exist in PATH + - curl + env: + required: # Env vars that MUST be set + - MY_API_KEY + one_of: [] # At least one of these must be set + optional: [] # Nice-to-have env vars + egress_domains: # Network domains this skill may contact + - api.example.com + denied_tools: # Tools this skill must NOT use + - http_request + # timeout_hint: 300 # Suggested timeout in seconds +--- +` + "```" + ` + +### 2. Markdown Body + +After the frontmatter, write the skill body in markdown: + +- **# Title** — skill title heading +- **Description** — what the skill does, who it's for +- **## Tool: tool_name** — each tool the skill provides (one or more) + - **` + "`" + `**Input:**` + "`" + `** — parameter documentation + - **` + "`" + `**Output:**` + "`" + `** — what the tool returns +- **## Safety Constraints** — security rules the skill enforces + +## Required Body Sections + +Every generated SKILL.md body MUST include ALL of the following: + +1. **# Title** — A clear, descriptive heading for the skill +2. **Description paragraph** — 2-3 sentences explaining what this skill does, who it is for, and the key value it provides +3. **## Tool: tool_name** sections (one per tool) — each MUST contain: + - **` + "`" + `**Input:**` + "`" + `** parameter table** with columns: Parameter | Type | Required | Description + - **` + "`" + `**Output:**` + "`" + `** JSON schema** showing the structure of what the tool returns + - **` + "`" + `**Examples:**` + "`" + `** table** with columns: User Request | Tool Input — at least 5 rows mapping natural-language requests to concrete tool invocations + - **Detection heuristics** — when should the agent pick this tool? List keyword patterns, intent signals, or trigger phrases +4. **## Safety Constraints** — explicit list of: + - Forbidden operations (what the skill must NEVER do) + - Read-only vs. mutating behavior + - Scope limitations (namespaces, repos, environments) +5. **## Important Notes** — gotchas, defaults, edge cases the agent should know + +## Script Quality Requirements + +When generating scripts (shell or Python): + +- Scripts MUST be COMPLETE and FUNCTIONAL — no TODOs, no "extend this" stubs, no placeholder logic +- Must handle: input validation, error handling, JSON output formatting +- The runtime passes JSON input as the first positional argument (` + "`" + `$1` + "`" + `). Scripts MUST read input via: + ` + "`" + `INPUT="${1:-}"` + "`" + ` + Do NOT read from stdin, do NOT use ` + "`" + `--input` + "`" + ` flags, do NOT use ` + "`" + `cat` + "`" + ` for input. Always ` + "`" + `$1` + "`" + `. +- Shell scripts must start with ` + "`" + `set -euo pipefail` + "`" + ` +- Include a usage header comment explaining what the script does and its expected input/output +- All scripts must produce structured JSON output, never raw text +- ALWAYS generate shell (.sh) scripts by default +- Only generate Python scripts when the user explicitly requests it or when the logic + genuinely requires complex data structures, HTTP client libraries, or parsing that + shell+jq cannot handle +- If generating a Python script, add python3 to requires.bins in the frontmatter +- **jq quoting in shell scripts**: Never use ` + "`" + `\"` + "`" + ` inside single-quoted jq expressions — + single quotes in bash have NO escape sequences. Use jq ` + "`" + `@tsv` + "`" + `/` + "`" + `@csv` + "`" + ` for tabular output + instead of string interpolation with nested quotes. For example: + WRONG: ` + "`" + `jq '.items[] | \"\\(.name)\\t\\(.labels[\\\"key\\\"])\"'` + "`" + ` + RIGHT: ` + "`" + `jq -r '.items[] | [.name, .labels["key"]] | @tsv'` + "`" + ` + +## Execution Paths + +### Binary-backed (no scripts/) +The skill delegates to an existing CLI binary declared in ` + "`" + `requires.bins` + "`" + `. +The agent uses ` + "`" + `cli_execute` + "`" + ` to run the binary. No scripts/ directory needed. + +Example: k8s-incident-triage uses ` + "`" + `kubectl` + "`" + ` — it only needs ` + "`" + `bins: [kubectl]` + "`" + ` in metadata. + +### Script-backed (with scripts/) +For custom logic, provide executable scripts in a ` + "`" + `scripts/` + "`" + ` directory. +Tool name maps to script: underscores → hyphens (e.g. ` + "`" + `my_search` + "`" + ` → ` + "`" + `scripts/my-search.sh` + "`" + `). + +## Script Decision Logic + +Prefer this order: +1. **No script** — if an existing binary (curl, kubectl, jq, etc.) can do the job +2. **Shell script** — for simple orchestration of CLI tools +3. **Python script** — only when complex logic, parsing, or API interaction is needed + +**Default to shell scripts**. Only use Python if the user explicitly requests it or +the task genuinely requires complex parsing/data structures that shell cannot handle. +The runtime executes all scripts via bash — Python scripts need ` + "`" + `python3` + "`" + ` in requires.bins. + +Always justify why a script is needed if you create one. + +## Security Model + +- **egress_domains**: Declare ALL external domains the skill contacts +- **bins**: Declare ALL binaries the skill requires +- **env categorization**: Properly classify env vars as required, one_of, or optional +- **denied_tools**: List tools the skill must NOT use (e.g. http_request if using cli_execute) +- **No ` + "`" + `sh -c` + "`" + `**: Never use shell command strings; use proper scripts instead + +## Output Format + +When you generate skill content, use QUADRUPLE-backtick labeled fences (` + "````" + ` not ` + "```" + `). +This is critical — inner triple-backtick code blocks (JSON schemas, etc.) must nest safely. + +For the SKILL.md content: +` + "`````" + ` +` + "````" + `skill.md +--- +name: example-skill +... +--- +# Example Skill +... +` + "````" + ` +` + "`````" + ` + +For optional scripts (only if needed): +` + "`````" + ` +` + "````" + `script:my-search.sh +#!/bin/bash +set -euo pipefail +... +` + "````" + ` +` + "`````" + ` + +## Complete Example: Binary-backed Skill (k8s-incident-triage) + +` + "````" + `skill.md +--- +name: k8s-incident-triage +category: sre +tags: + - kubernetes + - incident-response + - triage +description: Read-only Kubernetes incident triage using kubectl +metadata: + forge: + requires: + bins: + - kubectl + env: + optional: + - KUBECONFIG + - DEFAULT_NAMESPACE + egress_domains: + - "$K8S_API_DOMAIN" + denied_tools: + - http_request + - web_search + timeout_hint: 300 +--- + +# Kubernetes Incident Triage + +Performs read-only Kubernetes cluster investigation for incident response. Collects pod status, events, logs, resource usage, and network policies to identify root causes without making any changes to the cluster. + +## Tool: k8s_triage + +Investigate Kubernetes incidents by examining cluster state, pod health, events, and logs. + +**Input:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| namespace | string | No | Target namespace (default: from $DEFAULT_NAMESPACE or "default") | +| resource | string | No | Specific resource to investigate (e.g. "deploy/api-server") | +| time_range | string | No | How far back to look for events (e.g. "1h", "30m", default: "1h") | +| symptoms | string | Yes | Description of the observed problem | + +**Output:** + +` + "`" + `` + "`" + `` + "`" + `json +{ + "summary": "One-line incident summary", + "findings": [ + {"resource": "pod/api-xyz", "status": "CrashLoopBackOff", "detail": "OOMKilled after 512Mi limit"} + ], + "root_causes": ["Memory limit too low for current traffic volume"], + "next_commands": ["kubectl top pods -n production", "kubectl describe hpa api-server"], + "evidence": {"events": "...", "logs": "...", "resource_status": "..."} +} +` + "`" + `` + "`" + `` + "`" + ` + +**Examples:** + +| User Request | Tool Input | +|---|---| +| "Pods keep crashing in production" | {"namespace": "production", "symptoms": "pods crashing"} | +| "API server throwing 503s" | {"namespace": "default", "resource": "deploy/api-server", "symptoms": "503 errors from api-server"} | +| "High latency on checkout service last 30min" | {"namespace": "production", "resource": "deploy/checkout", "time_range": "30m", "symptoms": "high latency on checkout"} | +| "Nodes not ready in staging" | {"namespace": "staging", "symptoms": "nodes reporting NotReady status"} | +| "CronJob failed overnight" | {"namespace": "batch", "time_range": "12h", "symptoms": "scheduled cronjob did not complete"} | +| "Memory usage spiking on worker pods" | {"namespace": "production", "resource": "deploy/worker", "symptoms": "memory usage spiking"} | +| "Ingress returning 404 for /api routes" | {"namespace": "ingress-nginx", "symptoms": "ingress 404 errors on /api paths"} | +| "PVC stuck in pending state" | {"namespace": "data", "symptoms": "PersistentVolumeClaim not binding"} | +| "Service mesh sidecar injection failing" | {"namespace": "istio-system", "symptoms": "sidecar injection failures"} | + +**Detection heuristics** — use this tool when the user mentions: +- Kubernetes, k8s, pods, deployments, services, nodes, namespaces +- Crash loops, OOMKilled, restarts, pending, failed, not ready +- kubectl output or cluster investigation +- Incident response or triage for container workloads + +### Process + +1. Identify target namespace and resource from the symptoms +2. Run ` + "`" + `kubectl get events --sort-by=.lastTimestamp` + "`" + ` for recent cluster events +3. Check pod status with ` + "`" + `kubectl get pods` + "`" + ` — look for non-Running states +4. For crashing pods: ` + "`" + `kubectl logs --previous` + "`" + ` and ` + "`" + `kubectl describe pod` + "`" + ` +5. Check resource usage: ` + "`" + `kubectl top pods` + "`" + ` and ` + "`" + `kubectl top nodes` + "`" + ` +6. Examine related objects (HPA, PDB, NetworkPolicy, Ingress) +7. Correlate findings into root causes + +## Safety Constraints + +- **READ-ONLY**: Only use ` + "`" + `get` + "`" + `, ` + "`" + `describe` + "`" + `, ` + "`" + `logs` + "`" + `, ` + "`" + `top` + "`" + `, and ` + "`" + `explain` + "`" + ` subcommands +- **NEVER** run ` + "`" + `kubectl delete` + "`" + `, ` + "`" + `kubectl apply` + "`" + `, ` + "`" + `kubectl patch` + "`" + `, ` + "`" + `kubectl edit` + "`" + `, ` + "`" + `kubectl exec` + "`" + `, ` + "`" + `kubectl scale` + "`" + `, or ` + "`" + `kubectl rollout` + "`" + ` +- **NEVER** run ` + "`" + `kubectl port-forward` + "`" + ` or ` + "`" + `kubectl proxy` + "`" + ` +- Do not access secrets content (` + "`" + `kubectl get secret -o yaml` + "`" + ` is forbidden) +- Limit log retrieval to ` + "`" + `--tail=200` + "`" + ` to avoid excessive output + +## Important Notes + +- If KUBECONFIG is not set, kubectl uses the default ` + "`" + `~/.kube/config` + "`" + ` +- DEFAULT_NAMESPACE overrides "default" as the fallback namespace +- Always specify ` + "`" + `-n ` + "`" + ` explicitly; never rely on the current context namespace +- For multi-container pods, specify ` + "`" + `-c ` + "`" + ` when fetching logs +` + "````" + ` + +## Complete Example: Script-backed Skill (code-review) + +` + "````" + `skill.md +--- +name: code-review +category: developer +tags: + - code-review + - diff + - quality +description: Review code changes for quality, bugs, and best practices +metadata: + forge: + requires: + bins: + - git + env: + one_of: + - GITHUB_TOKEN + - GITLAB_TOKEN + optional: + - REVIEW_STYLE + egress_domains: + - api.github.com + - gitlab.com + denied_tools: + - web_search +--- + +# Code Review + +Reviews code diffs and files for bugs, security issues, performance problems, and style violations. Supports reviewing git diffs, specific files, or pull request changes. + +## Tool: code_review_diff + +Review a code diff for issues and provide actionable feedback. + +**Input:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| diff | string | Yes | The unified diff text to review | +| context | string | No | Additional context about the change (e.g. "refactoring auth module") | +| severity | string | No | Minimum severity to report: "info", "warning", "error" (default: "warning") | +| language | string | No | Programming language hint (auto-detected if omitted) | + +**Output:** + +` + "`" + `` + "`" + `` + "`" + `json +{ + "summary": "Overall review summary", + "issues": [ + { + "severity": "error", + "file": "src/auth.py", + "line": 42, + "message": "SQL injection via string concatenation", + "suggestion": "Use parameterized queries instead" + } + ], + "stats": {"errors": 1, "warnings": 2, "info": 0} +} +` + "`" + `` + "`" + `` + "`" + ` + +**Examples:** + +| User Request | Tool Input | +|---|---| +| "Review this PR diff" | {"diff": ""} | +| "Check this diff for security issues only" | {"diff": "", "severity": "error"} | +| "Review these Python changes for the auth refactor" | {"diff": "", "context": "auth module refactoring", "language": "python"} | +| "Quick review of my staged changes" | {"diff": ""} | +| "Review this diff, only show warnings and errors" | {"diff": "", "severity": "warning"} | +| "Check my Go code changes" | {"diff": "", "language": "go"} | +| "Review the database migration diff" | {"diff": "", "context": "database schema migration"} | +| "Look at this frontend diff for accessibility issues" | {"diff": "", "context": "accessibility review", "language": "typescript"} | + +**Detection heuristics** — use this tool when the user mentions: +- Reviewing code, diffs, pull requests, merge requests, changes +- Code quality, bugs, security review, style check +- "What do you think of this diff/change" +- git diff output or patch content + +## Safety Constraints + +- **READ-ONLY**: Never modify, commit, push, or approve any code +- **NEVER** run ` + "`" + `git push` + "`" + `, ` + "`" + `git commit` + "`" + `, ` + "`" + `git checkout` + "`" + `, ` + "`" + `git reset` + "`" + `, or ` + "`" + `git merge` + "`" + ` +- Do not execute any code found in diffs +- Do not access or display environment variables or secrets found in code +- Limit review scope to the provided diff — do not fetch additional repository content + +## Important Notes + +- GITHUB_TOKEN or GITLAB_TOKEN is only needed if fetching PR diffs from remote; local diffs need no token +- REVIEW_STYLE can be set to "concise" or "detailed" (default: "detailed") +- Auto-detects language from file extensions in the diff +- For large diffs (>2000 lines), consider splitting by file for better results +` + "````" + ` + +## Guidelines + +- Ask clarifying questions before generating — understand the use case first +- Generate complete, production-ready SKILL.md files +- Follow the exact YAML schema shown above +- Use lowercase kebab-case for name, category, and tags +- Include appropriate safety constraints +- Keep descriptions concise but informative +- If the user wants to iterate, update only the changed parts +- Every tool MUST have an Input parameter table, Output JSON schema, and Examples table +- Include at least 5 natural-language → tool-input example rows per tool +- Scripts must be complete and runnable — never use placeholder or stub logic +- Env vars must be categorized as required, one_of, or optional — never leave categories empty without reason +` diff --git a/forge-ui/skill_validator.go b/forge-ui/skill_validator.go new file mode 100644 index 0000000..2f3bbeb --- /dev/null +++ b/forge-ui/skill_validator.go @@ -0,0 +1,211 @@ +package forgeui + +import ( + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/initializ/forge/forge-skills/contract" + "github.com/initializ/forge/forge-skills/parser" +) + +var ( + skillNamePattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) + kebabCasePattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) + egressURLPattern = regexp.MustCompile(`https?://([^/\s"'` + "`" + `]+)`) + artifactFenceExpr = regexp.MustCompile("(?s)````(skill\\.md|script:[^\n]+)\n(.*?)````") +) + +// validateSkillMD validates SKILL.md content and optional scripts. +func validateSkillMD(content string, scripts map[string]string, agentDir string) SkillValidationResult { + var result SkillValidationResult + result.Valid = true + + if strings.TrimSpace(content) == "" { + result.Valid = false + result.Errors = append(result.Errors, ValidationError{ + Field: "skill_md", + Message: "SKILL.md content is empty", + }) + return result + } + + // Parse with metadata using the existing skill parser + entries, meta, err := parser.ParseWithMetadata(strings.NewReader(content)) + if err != nil { + result.Valid = false + result.Errors = append(result.Errors, ValidationError{ + Field: "yaml", + Message: "Failed to parse YAML frontmatter: " + err.Error(), + }) + return result + } + + // Check name + if meta == nil || meta.Name == "" { + result.Valid = false + result.Errors = append(result.Errors, ValidationError{ + Field: "name", + Message: "name is required in frontmatter", + }) + } else { + if !skillNamePattern.MatchString(meta.Name) { + result.Valid = false + result.Errors = append(result.Errors, ValidationError{ + Field: "name", + Message: "name must be lowercase kebab-case (e.g. my-skill)", + }) + } + if len(meta.Name) > 64 { + result.Valid = false + result.Errors = append(result.Errors, ValidationError{ + Field: "name", + Message: "name must be 64 characters or fewer", + }) + } + if strings.Contains(meta.Name, "/") || strings.Contains(meta.Name, "\\") || strings.Contains(meta.Name, "..") { + result.Valid = false + result.Errors = append(result.Errors, ValidationError{ + Field: "name", + Message: "name must not contain path separators or '..'", + }) + } + } + + // Check description + if meta == nil || meta.Description == "" { + result.Valid = false + result.Errors = append(result.Errors, ValidationError{ + Field: "description", + Message: "description is required in frontmatter", + }) + } + + // Warnings + + // Category format + if meta != nil && meta.Category != "" && !kebabCasePattern.MatchString(meta.Category) { + result.Warnings = append(result.Warnings, ValidationError{ + Field: "category", + Message: "category should be lowercase kebab-case", + }) + } + + // Body presence + if len(entries) == 0 { + result.Warnings = append(result.Warnings, ValidationError{ + Field: "body", + Message: "no ## Tool: sections found in the markdown body", + }) + } + + // Undeclared egress + if len(scripts) > 0 { + var declaredEgress []string + if meta != nil && meta.Metadata != nil { + declaredEgress = extractDeclaredEgress(meta) + } + undeclared := detectUndeclaredEgress(scripts, declaredEgress) + for _, domain := range undeclared { + result.Warnings = append(result.Warnings, ValidationError{ + Field: "egress_domains", + Message: "scripts reference domain " + domain + " which is not declared in egress_domains", + }) + } + } + + // Name uniqueness + if meta != nil && meta.Name != "" && agentDir != "" { + skillDir := filepath.Join(agentDir, "skills", meta.Name) + if _, err := os.Stat(skillDir); err == nil { + result.Warnings = append(result.Warnings, ValidationError{ + Field: "name", + Message: "a skill named '" + meta.Name + "' already exists in this agent", + }) + } + } + + return result +} + +// extractDeclaredEgress pulls egress_domains from the metadata forge section. +func extractDeclaredEgress(meta *contract.SkillMetadata) []string { + if meta.Metadata == nil { + return nil + } + forgeMap, ok := meta.Metadata["forge"] + if !ok || forgeMap == nil { + return nil + } + egressRaw, ok := forgeMap["egress_domains"] + if !ok || egressRaw == nil { + return nil + } + egressSlice, ok := egressRaw.([]any) + if !ok { + return nil + } + var domains []string + for _, v := range egressSlice { + if s, ok := v.(string); ok { + domains = append(domains, s) + } + } + return domains +} + +// detectUndeclaredEgress scans script contents for HTTP(S) URLs and returns +// domains not found in the declared egress list. +func detectUndeclaredEgress(scripts map[string]string, declaredEgress []string) []string { + declaredSet := make(map[string]bool, len(declaredEgress)) + for _, d := range declaredEgress { + declaredSet[strings.TrimPrefix(strings.TrimPrefix(d, "$"), "{")] = true + declaredSet[d] = true + } + + foundDomains := make(map[string]bool) + for _, content := range scripts { + matches := egressURLPattern.FindAllStringSubmatch(content, -1) + for _, m := range matches { + if len(m) > 1 { + domain := m[1] + // Strip port if present + if idx := strings.Index(domain, ":"); idx > 0 { + domain = domain[:idx] + } + foundDomains[domain] = true + } + } + } + + var undeclared []string + for domain := range foundDomains { + if !declaredSet[domain] { + undeclared = append(undeclared, domain) + } + } + return undeclared +} + +// extractArtifacts parses labeled code fences from an LLM response. +// Returns the SKILL.md content and a map of script filename → content. +func extractArtifacts(response string) (skillMD string, scripts map[string]string) { + scripts = make(map[string]string) + + matches := artifactFenceExpr.FindAllStringSubmatch(response, -1) + for _, m := range matches { + if len(m) < 3 { + continue + } + label := m[1] + content := m[2] + if label == "skill.md" { + skillMD = strings.TrimSpace(content) + } else if strings.HasPrefix(label, "script:") { + filename := strings.TrimPrefix(label, "script:") + scripts[filename] = strings.TrimRight(content, "\n") + } + } + return +} diff --git a/forge-ui/skill_validator_test.go b/forge-ui/skill_validator_test.go new file mode 100644 index 0000000..5d87e69 --- /dev/null +++ b/forge-ui/skill_validator_test.go @@ -0,0 +1,299 @@ +package forgeui + +import ( + "testing" +) + +func TestValidateSkillMDValid(t *testing.T) { + content := `--- +name: my-skill +description: A test skill +category: ops +tags: + - testing +metadata: + forge: + requires: + bins: + - curl + env: + required: + - MY_API_KEY + egress_domains: + - api.example.com +--- + +# My Skill + +## Tool: my_tool + +A test tool. + +**Input:** query string +**Output:** JSON results +` + + result := validateSkillMD(content, nil, "") + if !result.Valid { + t.Errorf("expected valid, got errors: %v", result.Errors) + } +} + +func TestValidateSkillMDMissingFrontmatter(t *testing.T) { + content := `# No frontmatter here + +## Tool: my_tool + +A test tool. +` + + result := validateSkillMD(content, nil, "") + if result.Valid { + t.Error("expected invalid for missing frontmatter") + } + + hasNameErr := false + for _, e := range result.Errors { + if e.Field == "name" { + hasNameErr = true + } + } + if !hasNameErr { + t.Error("expected name error") + } +} + +func TestValidateSkillMDMissingName(t *testing.T) { + content := `--- +description: A test skill +--- + +# My Skill +` + + result := validateSkillMD(content, nil, "") + if result.Valid { + t.Error("expected invalid for missing name") + } + + found := false + for _, e := range result.Errors { + if e.Field == "name" { + found = true + } + } + if !found { + t.Error("expected name error") + } +} + +func TestValidateSkillMDInvalidNameFormat(t *testing.T) { + tests := []struct { + name string + input string + wantErr bool + }{ + {"uppercase", "MySkill", true}, + {"spaces", "my skill", true}, + {"path separator", "my/skill", true}, + {"dots", "my..skill", true}, + {"valid kebab", "my-skill", false}, + {"valid single", "skill", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + content := "---\nname: " + tt.input + "\ndescription: test\n---\n# Test\n" + result := validateSkillMD(content, nil, "") + if tt.wantErr && result.Valid { + t.Errorf("expected invalid for name %q", tt.input) + } + if !tt.wantErr && !result.Valid { + t.Errorf("expected valid for name %q, got errors: %v", tt.input, result.Errors) + } + }) + } +} + +func TestValidateSkillMDMissingDescription(t *testing.T) { + content := `--- +name: my-skill +--- + +# My Skill +` + + result := validateSkillMD(content, nil, "") + if result.Valid { + t.Error("expected invalid for missing description") + } + + found := false + for _, e := range result.Errors { + if e.Field == "description" { + found = true + } + } + if !found { + t.Error("expected description error") + } +} + +func TestValidateSkillMDNoToolsWarning(t *testing.T) { + content := `--- +name: my-skill +description: A test skill +--- + +# My Skill + +Just some text, no tools. +` + + result := validateSkillMD(content, nil, "") + if !result.Valid { + t.Errorf("should be valid (missing tools is warning, not error)") + } + + found := false + for _, w := range result.Warnings { + if w.Field == "body" { + found = true + } + } + if !found { + t.Error("expected body warning for no tools") + } +} + +func TestDetectUndeclaredEgress(t *testing.T) { + scripts := map[string]string{ + "fetch.sh": `#!/bin/bash +curl https://api.example.com/data +curl https://other-api.com/stuff`, + } + + // Only api.example.com is declared + undeclared := detectUndeclaredEgress(scripts, []string{"api.example.com"}) + + if len(undeclared) != 1 { + t.Fatalf("expected 1 undeclared domain, got %d: %v", len(undeclared), undeclared) + } + if undeclared[0] != "other-api.com" { + t.Errorf("undeclared = %q, want %q", undeclared[0], "other-api.com") + } +} + +func TestDetectUndeclaredEgressAllDeclared(t *testing.T) { + scripts := map[string]string{ + "fetch.sh": `curl https://api.example.com/data`, + } + + undeclared := detectUndeclaredEgress(scripts, []string{"api.example.com"}) + if len(undeclared) != 0 { + t.Errorf("expected 0 undeclared domains, got %v", undeclared) + } +} + +func TestExtractArtifacts(t *testing.T) { + response := "Here is your skill:\n````skill.md\n---\nname: test\ndescription: test\n---\n# Test\n````\n\nAnd a script:\n````script:fetch.sh\n#!/bin/bash\necho hello\n````\n" + + skillMD, scripts := extractArtifacts(response) + + if skillMD == "" { + t.Error("expected non-empty skillMD") + } + if !contains(skillMD, "name: test") { + t.Errorf("skillMD should contain 'name: test', got: %q", skillMD) + } + + if len(scripts) != 1 { + t.Fatalf("expected 1 script, got %d", len(scripts)) + } + if _, ok := scripts["fetch.sh"]; !ok { + t.Error("expected script 'fetch.sh'") + } + if !contains(scripts["fetch.sh"], "echo hello") { + t.Errorf("script should contain 'echo hello', got: %q", scripts["fetch.sh"]) + } +} + +func TestExtractArtifactsNestedBackticks(t *testing.T) { + // Simulate LLM response where SKILL.md body contains inner triple-backtick JSON blocks + response := "Here is your skill:\n````skill.md\n---\nname: nested-test\ndescription: Skill with inner code blocks\n---\n# Nested Test\n\n## Tool: my_tool\n\n**Output:**\n\n```json\n{\"summary\": \"result\", \"items\": []}\n```\n\n## Safety Constraints\n\n- Read-only\n````\n" + + skillMD, scripts := extractArtifacts(response) + + if skillMD == "" { + t.Fatal("expected non-empty skillMD") + } + if !contains(skillMD, "name: nested-test") { + t.Errorf("skillMD missing frontmatter, got: %q", skillMD) + } + if !contains(skillMD, "```json") { + t.Errorf("skillMD should contain inner ```json block, got: %q", skillMD) + } + if !contains(skillMD, "Safety Constraints") { + t.Errorf("skillMD should contain content after inner code block, got: %q", skillMD) + } + if len(scripts) != 0 { + t.Errorf("expected 0 scripts, got %d", len(scripts)) + } +} + +func TestExtractArtifactsNoMatch(t *testing.T) { + response := "Just a regular response with no code fences." + skillMD, scripts := extractArtifacts(response) + + if skillMD != "" { + t.Errorf("expected empty skillMD, got %q", skillMD) + } + if len(scripts) != 0 { + t.Errorf("expected 0 scripts, got %d", len(scripts)) + } +} + +func TestValidateSkillMDUndeclaredEgressWarning(t *testing.T) { + content := `--- +name: my-skill +description: A test skill +--- + +# My Skill + +## Tool: my_tool + +A test tool. +` + scripts := map[string]string{ + "fetch.sh": `curl https://api.example.com/data`, + } + + result := validateSkillMD(content, scripts, "") + if !result.Valid { + t.Error("should be valid") + } + + found := false + for _, w := range result.Warnings { + if w.Field == "egress_domains" { + found = true + } + } + if !found { + t.Error("expected egress_domains warning for undeclared domain in scripts") + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsSubstr(s, substr)) +} + +func containsSubstr(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/forge-ui/static/dist/app.js b/forge-ui/static/dist/app.js index 9db77c3..483500b 100644 --- a/forge-ui/static/dist/app.js +++ b/forge-ui/static/dist/app.js @@ -132,6 +132,80 @@ async function fetchSkillContent(name) { return res.text(); } +// ── Skill Builder API Helpers ───────────────────────────────── + +async function fetchSkillBuilderProvider(agentId) { + const res = await fetch(`/api/agents/${agentId}/skill-builder/provider`); + if (!res.ok) throw new Error(`Failed to fetch provider: ${res.status}`); + return res.json(); +} + +async function streamSkillBuilderChat(agentId, messages, { onChunk, onSkillDraft, onError, onDone, signal }) { + const res = await fetch(`/api/agents/${agentId}/skill-builder/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ messages }), + signal, + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `Chat failed: ${res.status}`); + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + + // Parse SSE frames + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + let eventType = ''; + for (const line of lines) { + if (line.startsWith('event: ')) { + eventType = line.slice(7).trim(); + } else if (line.startsWith('data: ')) { + const data = line.slice(6); + try { + const parsed = JSON.parse(data); + if (eventType === 'chunk' && onChunk) onChunk(parsed.content || ''); + else if (eventType === 'skill_draft' && onSkillDraft) onSkillDraft(parsed); + else if (eventType === 'error' && onError) onError(parsed.error || 'Unknown error'); + else if (eventType === 'done' && onDone) onDone(); + } catch { /* ignore parse errors */ } + eventType = ''; + } + } + } +} + +async function validateSkillBuilderMD(agentId, skillMD, scripts) { + const res = await fetch(`/api/agents/${agentId}/skill-builder/validate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ skill_md: skillMD, scripts }), + }); + if (!res.ok) throw new Error(`Validation failed: ${res.status}`); + return res.json(); +} + +async function saveSkillBuilder(agentId, skillName, skillMD, scripts) { + const res = await fetch(`/api/agents/${agentId}/skill-builder/save`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ skill_name: skillName, skill_md: skillMD, scripts }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || `Save failed: ${res.status}`); + return data; +} + // ── SSE Hook ───────────────────────────────────────────────── function useSSE(onEvent) { @@ -184,6 +258,9 @@ function parseHash(hash) { if (configMatch) return { page: 'config', params: { id: configMatch[1] } }; // #/skills if (path === 'skills') return { page: 'skills', params: {} }; + // #/skill-builder/{id} + const sbMatch = path.match(/^skill-builder\/(.+)$/); + if (sbMatch) return { page: 'skill-builder', params: { id: sbMatch[1] } }; return { page: 'dashboard', params: {} }; } @@ -640,6 +717,9 @@ function AgentCard({ agent, onStart, onStop }) { + `; @@ -657,7 +737,7 @@ function EmptyState() { `; } -function Sidebar({ agents, activeAgentId, activePage }) { +function Sidebar({ agents, activeAgentId, activePage, version }) { return html` `; } @@ -1968,21 +2052,332 @@ function SkillsPage() { `; } +// ── Skill Builder Page ─────────────────────────────────────── + +function SkillBuilderPage({ agentId }) { + const [provider, setProvider] = useState(null); + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(''); + const [streaming, setStreaming] = useState(false); + const [skillMD, setSkillMD] = useState(''); + const [scripts, setScripts] = useState({}); + const [activeTab, setActiveTab] = useState('skill.md'); + const [validation, setValidation] = useState(null); + const [saveStatus, setSaveStatus] = useState(null); + const [error, setError] = useState(null); + const abortRef = useRef(null); + const chatEndRef = useRef(null); + const editorRef = useRef(null); + + // Fetch provider on mount + useEffect(() => { + fetchSkillBuilderProvider(agentId) + .then(setProvider) + .catch(err => setError('Failed to load provider: ' + err.message)); + }, [agentId]); + + // Auto-scroll chat + useEffect(() => { + if (chatEndRef.current) { + chatEndRef.current.scrollIntoView({ behavior: 'smooth' }); + } + }, [messages]); + + // Initialize Monaco for skill editor + useEffect(() => { + if (!editorRef.current) return; + const container = editorRef.current; + + // Try to load Monaco, fallback to textarea + const loadEditor = async () => { + try { + const monaco = await import('/monaco/editor.js'); + if (!container._monacoEditor) { + container._monacoEditor = monaco.editor.create(container, { + value: skillMD, + language: 'markdown', + theme: 'vs-dark', + minimap: { enabled: false }, + automaticLayout: true, + wordWrap: 'on', + fontSize: 13, + lineNumbers: 'on', + scrollBeyondLastLine: false, + }); + container._monacoEditor.onDidChangeModelContent(() => { + const val = container._monacoEditor.getValue(); + setSkillMD(val); + }); + } + } catch { + // Monaco not available — textarea fallback used + } + }; + loadEditor(); + + return () => { + if (container._monacoEditor) { + container._monacoEditor.dispose(); + container._monacoEditor = null; + } + }; + }, []); + + // Update editor content when skillMD changes from AI draft + useEffect(() => { + if (editorRef.current && editorRef.current._monacoEditor) { + const editor = editorRef.current._monacoEditor; + if (editor.getValue() !== skillMD) { + editor.setValue(skillMD); + } + } + }, [skillMD]); + + const handleSend = useCallback(async () => { + const text = input.trim(); + if (!text || streaming) return; + + const userMsg = { role: 'user', content: text }; + const newMessages = [...messages, userMsg]; + setMessages(newMessages); + setInput(''); + setStreaming(true); + setError(null); + + // Add placeholder assistant message + const assistantMsg = { role: 'assistant', content: '' }; + setMessages([...newMessages, assistantMsg]); + + const abort = new AbortController(); + abortRef.current = abort; + + try { + await streamSkillBuilderChat(agentId, newMessages, { + signal: abort.signal, + onChunk(content) { + assistantMsg.content += content; + setMessages([...newMessages, { ...assistantMsg }]); + }, + onSkillDraft(draft) { + if (draft.skill_md) setSkillMD(draft.skill_md); + if (draft.scripts) setScripts(draft.scripts || {}); + setValidation(null); + setSaveStatus(null); + }, + onError(errMsg) { + setError(errMsg); + }, + onDone() { + // streaming complete + }, + }); + } catch (err) { + if (err.name !== 'AbortError') { + setError(err.message); + } + } finally { + setStreaming(false); + abortRef.current = null; + } + }, [agentId, messages, input, streaming]); + + const handleValidate = useCallback(async () => { + try { + const result = await validateSkillBuilderMD(agentId, skillMD, scripts); + setValidation(result); + } catch (err) { + setError('Validation failed: ' + err.message); + } + }, [agentId, skillMD, scripts]); + + const handleSave = useCallback(async () => { + // Extract name from SKILL.md frontmatter + const nameMatch = skillMD.match(/^name:\s*(.+)$/m); + const skillName = nameMatch ? nameMatch[1].trim() : ''; + if (!skillName) { + setError('Cannot save: no "name" field found in SKILL.md frontmatter'); + return; + } + + try { + const result = await saveSkillBuilder(agentId, skillName, skillMD, scripts); + setSaveStatus(result); + setError(null); + } catch (err) { + setError('Save failed: ' + err.message); + } + }, [agentId, skillMD, scripts]); + + const handleKeyDown = useCallback((e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }, [handleSend]); + + const scriptTabs = Object.keys(scripts); + + return html` +
+
+
+

Skill Builder

+ ${provider && html` +
+ ${provider.provider}/${provider.model} + ${!provider.has_key && html`API key not configured`} +
+ `} +
+ +
+ ${messages.length === 0 && html` +
+
Design a new skill
+
Describe the skill you want to create. The AI will generate a valid SKILL.md file.
+
+ + + +
+
+ `} + ${messages.map((msg, i) => html` +
+
+
+ `)} + ${streaming && html`
Generating...
`} +
+
+ +
+