From a268417ab2f6179a3b9c8a683c3d468805342432 Mon Sep 17 00:00:00 2001 From: Willie Date: Sat, 18 Apr 2026 22:56:29 +0800 Subject: [PATCH 01/10] feat(ui): add web-based skill editor with metadata, live preview, and diff review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a full in-browser skill editor reachable from the skill detail page, replacing the previous read-only detail view for edits. Editor - Two-pane layout with Markdown textarea, live preview, and synced scroll - Edit / Split / Preview mode toggle in the status bar (⌘P to cycle) - Keyboard shortcuts: ⌘S save (with diff review), Esc cancel - Outline drawer with jump-to-heading - Token / word / line / file count in status bar, 5K budget warning Frontmatter - Grouped field editor (Identity / Invocation / Execution / Metadata) matching the 13 official SKILL.md fields - Shared 1,536-char budget enforced across description + when_to_use - Switch toggles for booleans, segmented control for enums, chip array inputs for list-valued fields - Metadata group edits nested metadata.* keys directly, with stable row ids so typing key → clicking value no longer remounts the inputs - Special handling for metadata.targets so values serialize as a YAML list (backend ParseFrontmatterList requires arrays) - YAML mode for raw editing; Fields ↔ YAML round-trips cleanly - Legacy root-level 'targets:' migrated to metadata.targets on load Diff review - Save triggers a side-by-side diff modal built on the shared DialogShell with focus trap and backdrop, using the Button component for actions - YAML serializer emits plain scalars when safe instead of wrapping every value containing ':', '*', '#', or '"', eliminating spurious churn in the save diff Misc - DialogShell gains 4xl–7xl maxWidth presets - Resource detail sidebar shows a Targets row when metadata.targets is set Backend additions (server handlers): - POST /api/resources/:name/content save SKILL.md content - POST /api/resources/:name/open open in local editor --- internal/server/handler_open_in_editor.go | 208 +++ .../server/handler_open_in_editor_test.go | 100 ++ internal/server/handler_skill_content.go | 169 +++ internal/server/handler_skill_content_test.go | 139 ++ internal/server/server.go | 2 + ui/src/api/client.ts | 25 + ui/src/components/DialogShell.tsx | 4 + ui/src/components/ScrollToTop.tsx | 19 +- ui/src/components/skill-editor/DiffView.tsx | 199 +++ .../skill-editor/FrontmatterEditor.tsx | 606 ++++++++ ui/src/components/skill-editor/Outline.tsx | 187 +++ .../components/skill-editor/SkillEditor.tsx | 757 ++++++++++ .../skill-editor/controls/CharBudget.tsx | 20 + .../skill-editor/controls/SegmentedField.tsx | 24 + .../skill-editor/controls/SwitchToggle.tsx | 23 + ui/src/components/skill-editor/index.ts | 5 + ui/src/components/skill-editor/styles.css | 1322 +++++++++++++++++ ui/src/lib/frontmatter.test.ts | 79 + ui/src/lib/frontmatter.ts | 216 +++ ui/src/pages/ResourceDetailPage.tsx | 103 +- 20 files changed, 4203 insertions(+), 4 deletions(-) create mode 100644 internal/server/handler_open_in_editor.go create mode 100644 internal/server/handler_open_in_editor_test.go create mode 100644 internal/server/handler_skill_content.go create mode 100644 internal/server/handler_skill_content_test.go create mode 100644 ui/src/components/skill-editor/DiffView.tsx create mode 100644 ui/src/components/skill-editor/FrontmatterEditor.tsx create mode 100644 ui/src/components/skill-editor/Outline.tsx create mode 100644 ui/src/components/skill-editor/SkillEditor.tsx create mode 100644 ui/src/components/skill-editor/controls/CharBudget.tsx create mode 100644 ui/src/components/skill-editor/controls/SegmentedField.tsx create mode 100644 ui/src/components/skill-editor/controls/SwitchToggle.tsx create mode 100644 ui/src/components/skill-editor/index.ts create mode 100644 ui/src/components/skill-editor/styles.css create mode 100644 ui/src/lib/frontmatter.test.ts create mode 100644 ui/src/lib/frontmatter.ts diff --git a/internal/server/handler_open_in_editor.go b/internal/server/handler_open_in_editor.go new file mode 100644 index 00000000..a6ee1d2c --- /dev/null +++ b/internal/server/handler_open_in_editor.go @@ -0,0 +1,208 @@ +package server + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" +) + +// openInEditorRequest controls which editor to launch. +// editor="auto" (or empty) walks a platform-aware fallback chain. +type openInEditorRequest struct { + Editor string `json:"editor,omitempty"` +} + +type openInEditorResponse struct { + Editor string `json:"editor"` + Path string `json:"path"` + PID int `json:"pid"` +} + +// editorCandidate represents an external editor we can try to launch. +type editorCandidate struct { + // name is the alias the API accepts (e.g. "code", "cursor"). + name string + // bin is the executable to spawn. + bin string + // args are appended before the file path. + args []string +} + +// knownEditors returns explicit editor aliases the API accepts. +func knownEditors() map[string]editorCandidate { + return map[string]editorCandidate{ + "code": {name: "code", bin: "code", args: []string{"--goto"}}, + "cursor": {name: "cursor", bin: "cursor", args: nil}, + "windsurf": {name: "windsurf", bin: "windsurf", args: nil}, + "subl": {name: "subl", bin: "subl", args: nil}, + "sublime": {name: "subl", bin: "subl", args: nil}, + "vim": {name: "vim", bin: "vim", args: nil}, + "nvim": {name: "nvim", bin: "nvim", args: nil}, + "nano": {name: "nano", bin: "nano", args: nil}, + "emacs": {name: "emacs", bin: "emacs", args: nil}, + "idea": {name: "idea", bin: "idea", args: nil}, + "webstorm": {name: "webstorm", bin: "webstorm", args: nil}, + "goland": {name: "goland", bin: "goland", args: nil}, + "textmate": {name: "textmate", bin: "mate", args: nil}, + "mate": {name: "mate", bin: "mate", args: nil}, + "zed": {name: "zed", bin: "zed", args: nil}, + } +} + +// handleOpenSkillInEditor launches the configured (or auto-detected) local +// editor against the skill's canonical markdown file. +// +// POST /api/resources/{name}/open-in-editor +// Body: {"editor": "auto" | "code" | "cursor" | "vim" | ...} +func (s *Server) handleOpenSkillInEditor(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + s.mu.RLock() + source := s.skillsSource() + agentsSource := s.agentsSource() + s.mu.RUnlock() + + name := r.PathValue("name") + kind := r.URL.Query().Get("kind") + if kind != "" && kind != "skill" && kind != "agent" { + writeError(w, http.StatusBadRequest, "invalid kind: "+kind) + return + } + + var req openInEditorRequest + // Body is optional; silently ignore decode errors when empty. + if r.Body != nil { + _ = json.NewDecoder(r.Body).Decode(&req) + } + + targetPath, resolvedKind, err := s.resolveEditableSkillPath(source, agentsSource, name, kind) + if err != nil { + writeError(w, http.StatusNotFound, err.Error()) + return + } + + // Skills are directories (SKILL.md + supporting files); open the folder + // so editors like VS Code / Cursor load the whole workspace. Agents are + // single .md files and stay as-is. + if resolvedKind == "skill" { + targetPath = filepath.Dir(targetPath) + } + + if os.Getenv("SKILLSHARE_HEADLESS") == "1" { + writeError(w, http.StatusConflict, "refusing to launch editor: SKILLSHARE_HEADLESS=1") + return + } + + editor, picked, err := pickEditor(strings.TrimSpace(req.Editor)) + if err != nil { + writeError(w, http.StatusConflict, "no editor available: "+err.Error()) + return + } + + cmd := exec.Command(editor.bin, append(editor.args, targetPath)...) //nolint:gosec // editor choice is explicit + // Detach from the current process so the server doesn't wait on the editor. + cmd.Stdin = nil + cmd.Stdout = nil + cmd.Stderr = nil + + if err := cmd.Start(); err != nil { + writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to launch %s: %s", editor.bin, err)) + return + } + // Reap in the background so we don't accumulate zombies on *nix. + go func() { _ = cmd.Wait() }() + + s.writeOpsLog("skill.openInEditor", "ok", start, map[string]any{ + "name": name, + "kind": resolvedKind, + "editor": picked, + "path": targetPath, + }, "") + + writeJSON(w, openInEditorResponse{ + Editor: picked, + Path: targetPath, + PID: cmd.Process.Pid, + }) +} + +// pickEditor returns the editor to launch, honouring an explicit request +// (e.g. "code") when it resolves, otherwise walking the fallback chain. +func pickEditor(requested string) (editorCandidate, string, error) { + if requested != "" && requested != "auto" { + if cand, ok := knownEditors()[strings.ToLower(requested)]; ok { + if _, err := exec.LookPath(cand.bin); err == nil { + return cand, cand.name, nil + } + return editorCandidate{}, "", fmt.Errorf("%s not found on PATH", cand.bin) + } + // Unknown alias — treat as raw binary. + if _, err := exec.LookPath(requested); err == nil { + return editorCandidate{name: requested, bin: requested}, requested, nil + } + return editorCandidate{}, "", fmt.Errorf("%s not found on PATH", requested) + } + + // Auto: respect $VISUAL / $EDITOR first. + for _, env := range []string{"VISUAL", "EDITOR"} { + if v := strings.TrimSpace(os.Getenv(env)); v != "" { + // $EDITOR may include flags, e.g. "code --wait". + head, rest := splitCommand(v) + if _, err := exec.LookPath(head); err == nil { + return editorCandidate{name: head, bin: head, args: rest}, head, nil + } + } + } + + // GUI IDE preferences. + for _, alias := range []string{"code", "cursor", "zed", "subl", "windsurf", "idea"} { + if cand, ok := knownEditors()[alias]; ok { + if _, err := exec.LookPath(cand.bin); err == nil { + return cand, cand.name, nil + } + } + } + + // OS native fallback — opens with the user's default handler. + switch runtime.GOOS { + case "darwin": + if _, err := exec.LookPath("open"); err == nil { + return editorCandidate{name: "open", bin: "open"}, "open", nil + } + case "linux": + if _, err := exec.LookPath("xdg-open"); err == nil { + return editorCandidate{name: "xdg-open", bin: "xdg-open"}, "xdg-open", nil + } + case "windows": + if _, err := exec.LookPath("rundll32"); err == nil { + return editorCandidate{name: "rundll32", bin: "rundll32", args: []string{"url.dll,FileProtocolHandler"}}, "rundll32", nil + } + } + + // Terminal fallbacks — nano is friendlier than vim for newcomers. + for _, alias := range []string{"nano", "vim", "nvim", "emacs"} { + if cand, ok := knownEditors()[alias]; ok { + if _, err := exec.LookPath(cand.bin); err == nil { + return cand, cand.name, nil + } + } + } + + return editorCandidate{}, "", fmt.Errorf("no usable editor found; set $EDITOR or pass editor=") +} + +// splitCommand naively splits an $EDITOR value into (bin, args...). +// Quoted arguments are not supported because $EDITOR rarely needs them. +func splitCommand(v string) (string, []string) { + fields := strings.Fields(v) + if len(fields) == 0 { + return "", nil + } + return fields[0], fields[1:] +} diff --git a/internal/server/handler_open_in_editor_test.go b/internal/server/handler_open_in_editor_test.go new file mode 100644 index 00000000..262674e0 --- /dev/null +++ b/internal/server/handler_open_in_editor_test.go @@ -0,0 +1,100 @@ +package server + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" +) + +func TestHandleOpenInEditor_UsesEchoAsEditor(t *testing.T) { + s, src := newTestServer(t) + addSkill(t, src, "my-skill") + + // `true` is a universally-available no-op binary on POSIX. + body := openInEditorRequest{Editor: "true"} + raw, _ := json.Marshal(body) + req := httptest.NewRequest(http.MethodPost, "/api/resources/my-skill/open-in-editor", bytes.NewReader(raw)) + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + var resp openInEditorResponse + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.PID == 0 { + t.Errorf("expected pid > 0, got %d", resp.PID) + } + if resp.Path != filepath.Join(src, "my-skill") { + t.Errorf("unexpected path: %s", resp.Path) + } +} + +func TestHandleOpenInEditor_NotFound(t *testing.T) { + s, _ := newTestServer(t) + raw, _ := json.Marshal(openInEditorRequest{Editor: "true"}) + req := httptest.NewRequest(http.MethodPost, "/api/resources/no-skill/open-in-editor", bytes.NewReader(raw)) + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", rr.Code, rr.Body.String()) + } +} + +func TestHandleOpenInEditor_HeadlessRefuses(t *testing.T) { + t.Setenv("SKILLSHARE_HEADLESS", "1") + s, src := newTestServer(t) + addSkill(t, src, "my-skill") + + raw, _ := json.Marshal(openInEditorRequest{Editor: "true"}) + req := httptest.NewRequest(http.MethodPost, "/api/resources/my-skill/open-in-editor", bytes.NewReader(raw)) + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusConflict { + t.Fatalf("expected 409, got %d", rr.Code) + } +} + +func TestHandleOpenInEditor_UnknownBinary(t *testing.T) { + s, src := newTestServer(t) + addSkill(t, src, "my-skill") + + raw, _ := json.Marshal(openInEditorRequest{Editor: "this-is-not-a-real-editor-xyz"}) + req := httptest.NewRequest(http.MethodPost, "/api/resources/my-skill/open-in-editor", bytes.NewReader(raw)) + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusConflict { + t.Fatalf("expected 409, got %d", rr.Code) + } +} + +func TestPickEditor_AutoPrefersEditorEnv(t *testing.T) { + t.Setenv("VISUAL", "") + t.Setenv("EDITOR", "true") + + cand, name, err := pickEditor("auto") + if err != nil { + t.Fatalf("pickEditor: %v", err) + } + if cand.bin != "true" || name != "true" { + t.Errorf("expected bin=true, got bin=%s name=%s", cand.bin, name) + } +} + +func TestSplitCommand(t *testing.T) { + head, rest := splitCommand("code --wait --new-window") + if head != "code" { + t.Errorf("expected head=code, got %s", head) + } + if len(rest) != 2 || rest[0] != "--wait" || rest[1] != "--new-window" { + t.Errorf("unexpected rest: %v", rest) + } +} diff --git a/internal/server/handler_skill_content.go b/internal/server/handler_skill_content.go new file mode 100644 index 00000000..43651af7 --- /dev/null +++ b/internal/server/handler_skill_content.go @@ -0,0 +1,169 @@ +package server + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "skillshare/internal/resource" + "skillshare/internal/sync" +) + +// skillContentRequest is the JSON body for PUT /api/resources/{name}/content +type skillContentRequest struct { + Content string `json:"content"` +} + +// skillContentResponse is the JSON response on successful save. +type skillContentResponse struct { + BytesWritten int `json:"bytesWritten"` + Path string `json:"path"` + ContentType string `json:"contentType"` + SavedAt string `json:"savedAt"` +} + +// handlePutSkillContent saves the SKILL.md (or agent markdown) content to disk. +// Request body: {"content": "..."} +// Writes atomically via .tmp + rename. +func (s *Server) handlePutSkillContent(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + s.mu.RLock() + source := s.skillsSource() + agentsSource := s.agentsSource() + s.mu.RUnlock() + + name := r.PathValue("name") + kind := r.URL.Query().Get("kind") + if kind != "" && kind != "skill" && kind != "agent" { + writeError(w, http.StatusBadRequest, "invalid kind: "+kind) + return + } + + var req skillContentRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error()) + return + } + + // Reject writes above a conservative ceiling to guard against accidents. + const maxBytes = 2 * 1024 * 1024 // 2 MiB + if len(req.Content) > maxBytes { + writeError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("content exceeds %d bytes", maxBytes)) + return + } + + targetPath, resolvedKind, err := s.resolveEditableSkillPath(source, agentsSource, name, kind) + if err != nil { + writeError(w, http.StatusNotFound, err.Error()) + return + } + + if err := writeFileAtomic(targetPath, []byte(req.Content), 0o644); err != nil { + writeError(w, http.StatusInternalServerError, "failed to save: "+err.Error()) + return + } + + s.writeOpsLog("skill.edit", "ok", start, map[string]any{ + "name": name, + "kind": resolvedKind, + "path": targetPath, + "length": len(req.Content), + }, "") + + writeJSON(w, skillContentResponse{ + BytesWritten: len(req.Content), + Path: targetPath, + ContentType: "text/markdown", + SavedAt: time.Now().UTC().Format(time.RFC3339), + }) +} + +// resolveEditableSkillPath locates the on-disk markdown file for a skill or agent. +// For skills this is /SKILL.md; for agents this is the agent's single .md file. +// Returns (absPath, resolvedKind, error). +func (s *Server) resolveEditableSkillPath(source, agentsSource, name, kind string) (string, string, error) { + if kind != "agent" && source != "" { + discovered, err := sync.DiscoverSourceSkillsAll(source) + if err == nil { + for _, d := range discovered { + baseName := filepath.Base(d.SourcePath) + if d.FlatName != name && baseName != name { + continue + } + skillMd := filepath.Join(d.SourcePath, "SKILL.md") + if !withinDir(skillMd, d.SourcePath) { + return "", "", fmt.Errorf("invalid skill path") + } + return skillMd, "skill", nil + } + } + } + + if kind != "skill" && agentsSource != "" { + agents, _ := resource.AgentKind{}.Discover(agentsSource) + for _, d := range agents { + if !matchesAgentName(d, name) { + continue + } + if !withinDir(d.SourcePath, agentsSource) { + return "", "", fmt.Errorf("invalid agent path") + } + return d.SourcePath, "agent", nil + } + } + + return "", "", fmt.Errorf("skill not found: %s", name) +} + +// withinDir reports whether path is inside dir (after cleaning). +func withinDir(path, dir string) bool { + abs := filepath.Clean(path) + base := filepath.Clean(dir) + string(filepath.Separator) + // If dir is exactly the file (e.g. single-file agent), treat as within. + if abs == filepath.Clean(dir) { + return true + } + return strings.HasPrefix(abs, base) +} + +// writeFileAtomic writes data to a temp file in the same directory, then renames it +// into place. This prevents partial writes if the process is killed mid-write. +func writeFileAtomic(path string, data []byte, perm os.FileMode) error { + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, ".skillshare-"+filepath.Base(path)+".*") + if err != nil { + return fmt.Errorf("create temp: %w", err) + } + tmpPath := tmp.Name() + cleanup := func() { _ = os.Remove(tmpPath) } + + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + cleanup() + return fmt.Errorf("write temp: %w", err) + } + if err := tmp.Chmod(perm); err != nil { + _ = tmp.Close() + cleanup() + return fmt.Errorf("chmod temp: %w", err) + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + cleanup() + return fmt.Errorf("sync temp: %w", err) + } + if err := tmp.Close(); err != nil { + cleanup() + return fmt.Errorf("close temp: %w", err) + } + if err := os.Rename(tmpPath, path); err != nil { + cleanup() + return fmt.Errorf("rename temp: %w", err) + } + return nil +} diff --git a/internal/server/handler_skill_content_test.go b/internal/server/handler_skill_content_test.go new file mode 100644 index 00000000..5c3f36cf --- /dev/null +++ b/internal/server/handler_skill_content_test.go @@ -0,0 +1,139 @@ +package server + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestHandlePutSkillContent_WritesFile(t *testing.T) { + s, src := newTestServer(t) + addSkill(t, src, "my-skill") + + body := skillContentRequest{Content: "---\nname: my-skill\ndescription: edited\n---\n# Updated"} + raw, _ := json.Marshal(body) + req := httptest.NewRequest(http.MethodPut, "/api/resources/my-skill/content", bytes.NewReader(raw)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + + var resp skillContentResponse + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("response decode: %v", err) + } + if resp.BytesWritten != len(body.Content) { + t.Errorf("expected %d bytes written, got %d", len(body.Content), resp.BytesWritten) + } + + on := filepath.Join(src, "my-skill", "SKILL.md") + got, err := os.ReadFile(on) + if err != nil { + t.Fatalf("read saved file: %v", err) + } + if string(got) != body.Content { + t.Errorf("file content mismatch:\nwant: %q\ngot: %q", body.Content, got) + } +} + +func TestHandlePutSkillContent_NotFound(t *testing.T) { + s, _ := newTestServer(t) + + body := skillContentRequest{Content: "foo"} + raw, _ := json.Marshal(body) + req := httptest.NewRequest(http.MethodPut, "/api/resources/nope/content", bytes.NewReader(raw)) + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", rr.Code, rr.Body.String()) + } +} + +func TestHandlePutSkillContent_InvalidJSON(t *testing.T) { + s, src := newTestServer(t) + addSkill(t, src, "my-skill") + + req := httptest.NewRequest(http.MethodPut, "/api/resources/my-skill/content", strings.NewReader("not-json")) + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", rr.Code, rr.Body.String()) + } +} + +func TestHandlePutSkillContent_TooLarge(t *testing.T) { + s, src := newTestServer(t) + addSkill(t, src, "my-skill") + + // Assemble a 3 MiB body — exceeds the 2 MiB ceiling. + huge := make([]byte, 3*1024*1024) + for i := range huge { + huge[i] = 'x' + } + body := skillContentRequest{Content: string(huge)} + raw, _ := json.Marshal(body) + req := httptest.NewRequest(http.MethodPut, "/api/resources/my-skill/content", bytes.NewReader(raw)) + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("expected 413, got %d", rr.Code) + } +} + +func TestHandlePutSkillContent_AtomicTempCleanup(t *testing.T) { + s, src := newTestServer(t) + addSkill(t, src, "my-skill") + + body := skillContentRequest{Content: "new content"} + raw, _ := json.Marshal(body) + req := httptest.NewRequest(http.MethodPut, "/api/resources/my-skill/content", bytes.NewReader(raw)) + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("unexpected %d", rr.Code) + } + + entries, err := os.ReadDir(filepath.Join(src, "my-skill")) + if err != nil { + t.Fatalf("readdir: %v", err) + } + for _, e := range entries { + if strings.HasPrefix(e.Name(), ".skillshare-") { + t.Errorf("temp file left behind: %s", e.Name()) + } + } +} + +func TestHandlePutSkillContent_RejectsTraversal(t *testing.T) { + // The route only exposes {name}; Go's mux rejects a literal "/" inside + // {name}, but we still want to confirm `..` as a name doesn't escape. + s, src := newTestServer(t) + addSkill(t, src, "victim") + + body := skillContentRequest{Content: "boom"} + raw, _ := json.Marshal(body) + req := httptest.NewRequest(http.MethodPut, "/api/resources/..%2Fescape/content", bytes.NewReader(raw)) + rr := httptest.NewRecorder() + s.handler.ServeHTTP(rr, req) + + // Any of: 400 / 404 is acceptable — what matters is that the victim's file + // is untouched. + got, err := os.ReadFile(filepath.Join(src, "victim", "SKILL.md")) + if err != nil { + t.Fatalf("read victim: %v", err) + } + if !strings.Contains(string(got), "# victim") { + t.Errorf("victim content was modified: %q", got) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index c48d1188..98e331f5 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -364,6 +364,8 @@ func (s *Server) registerRoutes() { s.mux.HandleFunc("POST /api/resources", s.handleCreateSkill) s.mux.HandleFunc("GET /api/resources/{name}", s.handleGetSkill) s.mux.HandleFunc("GET /api/resources/{name}/files/{filepath...}", s.handleGetSkillFile) + s.mux.HandleFunc("PUT /api/resources/{name}/content", s.handlePutSkillContent) + s.mux.HandleFunc("POST /api/resources/{name}/open-in-editor", s.handleOpenSkillInEditor) s.mux.HandleFunc("POST /api/resources/{name}/disable", s.handleDisableSkill) s.mux.HandleFunc("POST /api/resources/{name}/enable", s.handleEnableSkill) s.mux.HandleFunc("DELETE /api/resources/{name}", s.handleUninstallSkill) diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index 9ed58a13..590e5da7 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -323,6 +323,31 @@ export const api = { getSkillFile: (skillName: string, filepath: string) => apiFetch(`/resources/${encodeURIComponent(skillName)}/files/${filepath}`), + // Save SKILL.md / agent markdown. + saveSkillContent: (name: string, content: string, kind?: 'skill' | 'agent') => + apiFetch<{ + bytesWritten: number; + path: string; + contentType: string; + savedAt: string; + }>(`/resources/${encodeURIComponent(name)}/content${kind ? `?kind=${kind}` : ''}`, { + method: 'PUT', + body: JSON.stringify({ content }), + }), + + // Launch an external editor (VS Code / Cursor / $EDITOR) against the skill file. + openSkillInEditor: ( + name: string, + opts?: { editor?: string; kind?: 'skill' | 'agent' } + ) => + apiFetch<{ editor: string; path: string; pid: number }>( + `/resources/${encodeURIComponent(name)}/open-in-editor${opts?.kind ? `?kind=${opts.kind}` : ''}`, + { + method: 'POST', + body: JSON.stringify({ editor: opts?.editor ?? 'auto' }), + } + ), + // Collect collectScan: (target?: string, kind?: 'skill' | 'agent') => { const params = new URLSearchParams(); diff --git a/ui/src/components/DialogShell.tsx b/ui/src/components/DialogShell.tsx index 8d4a4de0..7f7643ef 100644 --- a/ui/src/components/DialogShell.tsx +++ b/ui/src/components/DialogShell.tsx @@ -9,6 +9,10 @@ const maxWidthClass = { xl: 'max-w-xl', '2xl': 'max-w-2xl', '3xl': 'max-w-3xl', + '4xl': 'max-w-4xl', + '5xl': 'max-w-5xl', + '6xl': 'max-w-6xl', + '7xl': 'max-w-7xl', } as const; const paddingClass = { diff --git a/ui/src/components/ScrollToTop.tsx b/ui/src/components/ScrollToTop.tsx index ad064d0a..39f73348 100644 --- a/ui/src/components/ScrollToTop.tsx +++ b/ui/src/components/ScrollToTop.tsx @@ -5,9 +5,12 @@ import { radius } from '../design'; interface ScrollToTopProps { /** Scroll threshold in pixels before the button appears (default: 400) */ threshold?: number; + /** 'floating' pins the button to bottom-right (legacy); + * 'inline' renders a compact button that fits inside a sticky header/toolbar. */ + variant?: 'floating' | 'inline'; } -export default function ScrollToTop({ threshold = 400 }: ScrollToTopProps) { +export default function ScrollToTop({ threshold = 400, variant = 'floating' }: ScrollToTopProps) { const [visible, setVisible] = useState(false); useEffect(() => { @@ -19,6 +22,20 @@ export default function ScrollToTop({ threshold = 400 }: ScrollToTopProps) { if (!visible) return null; + if (variant === 'inline') { + return ( + + ); + } + return ( + + + + + ); +} + +interface DiffCellProps { + side: 'left' | 'right'; + kind: 'eq' | 'del' | 'ins'; + data: { n: number; s: string } | null; +} + +function DiffCell({ side, kind, data }: DiffCellProps): ReactNode { + const gutter = + kind === 'del' && side === 'left' + ? '−' + : kind === 'ins' && side === 'right' + ? '+' + : '\u00a0'; + const bgClass = + kind === 'del' && side === 'left' + ? 'bg-danger/10' + : kind === 'ins' && side === 'right' + ? 'bg-success/10' + : ''; + const gutterColor = + kind === 'del' && side === 'left' + ? 'text-danger' + : kind === 'ins' && side === 'right' + ? 'text-success' + : 'text-muted-dark'; + const borderClass = side === 'right' ? 'border-l border-muted' : ''; + return ( +
+ + {data ? data.n : ''} + + {gutter} + + {data ? data.s || '\u00a0' : ''} + +
+ ); +} diff --git a/ui/src/components/skill-editor/FrontmatterEditor.tsx b/ui/src/components/skill-editor/FrontmatterEditor.tsx new file mode 100644 index 00000000..326c8ab1 --- /dev/null +++ b/ui/src/components/skill-editor/FrontmatterEditor.tsx @@ -0,0 +1,606 @@ +import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import { Code2, LayoutGrid, Plus, X } from 'lucide-react'; +import type { Frontmatter, FrontmatterValue } from '../../lib/frontmatter'; +import { serializeFrontmatter } from '../../lib/frontmatter'; +import { Input } from '../Input'; +import SwitchToggle from './controls/SwitchToggle'; +import SegmentedField from './controls/SegmentedField'; +import CharBudget from './controls/CharBudget'; + +const DESC_BUDGET = 1536; + +type FieldType = + | 'text' // single-line input + | 'multiline' // textarea + | 'array' // tag chips + | 'enum' // segmented (Task 5 will swap to SegmentedField) + | 'bool' // toggle (Task 5 will swap to SwitchToggle) + | 'conditional'; // shown only when another field has a value + +interface FieldDef { + key: string; + label: string; + hint: string; + type: FieldType; + required?: boolean; + options?: string[]; + showWhen?: { key: string; value: string }; + arrayPlaceholder?: string; + arrayItemLabel?: string; + rows?: number; +} + +interface GroupDef { + id: 'identity' | 'invocation' | 'execution'; + label: string; + defaultOpen: boolean; + fields: FieldDef[]; +} + +const GROUPS: GroupDef[] = [ + { + id: 'identity', + label: 'Identity', + defaultOpen: true, + fields: [ + { + key: 'name', + label: 'name', + hint: 'Skill identifier. Used by / invocation.', + type: 'text', + required: true, + }, + { + key: 'description', + label: 'description', + hint: 'One-line summary — shown in skill lists and routing.', + type: 'multiline', + required: true, + rows: 5, + }, + { + key: 'when_to_use', + label: 'when_to_use', + hint: 'Trigger phrases or example requests. Shares the 1,536-char budget with description.', + type: 'multiline', + rows: 3, + }, + ], + }, + { + id: 'invocation', + label: 'Invocation', + defaultOpen: true, + fields: [ + { + key: 'argument-hint', + label: 'argument-hint', + hint: 'Placeholder shown during autocomplete. e.g. [issue-number]', + type: 'text', + }, + { + key: 'paths', + label: 'paths', + hint: 'Glob patterns that limit when this skill auto-activates.', + type: 'array', + arrayPlaceholder: 'src/**/*.ts', + arrayItemLabel: 'path', + }, + { + key: 'disable-model-invocation', + label: 'disable-model-invocation', + hint: "Only the user can trigger via /name. Claude won't auto-load.", + type: 'bool', + }, + { + key: 'user-invocable', + label: 'user-invocable', + hint: 'Hide from / menu. Background knowledge only — Claude can still load it.', + type: 'bool', + }, + ], + }, + { + id: 'execution', + label: 'Execution', + defaultOpen: false, + fields: [ + { + key: 'allowed-tools', + label: 'allowed-tools', + hint: 'Tools Claude can use without per-call approval while this skill is active.', + type: 'array', + arrayPlaceholder: 'Tool(pattern:*)', + arrayItemLabel: 'tool', + }, + { + key: 'context', + label: 'context', + hint: 'Set "fork" to run in a forked subagent context.', + type: 'enum', + options: ['', 'fork'], + }, + { + key: 'agent', + label: 'agent', + hint: 'Subagent type. Only used when context=fork.', + type: 'conditional', + showWhen: { key: 'context', value: 'fork' }, + }, + { + key: 'shell', + label: 'shell', + hint: 'Shell for !`...` and ```! blocks.', + type: 'enum', + options: ['', 'bash', 'powershell'], + }, + ], + }, +]; + +export const FM_FIELD_ORDER = GROUPS.flatMap((g) => g.fields.map((f) => f.key)); + +interface FrontmatterEditorProps { + frontmatter: Frontmatter; + onChange: (next: Frontmatter) => void; + yamlMode: boolean; + onToggleYaml: (next: boolean) => void; + metadataHint?: ReactNode; +} + +export default function FrontmatterEditor({ + frontmatter, + onChange, + yamlMode, + onToggleYaml, + metadataHint, +}: FrontmatterEditorProps) { + const yaml = useMemo(() => serializeFrontmatter(frontmatter, FM_FIELD_ORDER), [frontmatter]); + + const setField = (key: string, value: string | string[] | boolean | null) => { + const next = { ...frontmatter }; + if (value == null || value === '' || (Array.isArray(value) && value.length === 0)) { + delete next[key]; + } else { + (next as Record)[key] = value; + } + onChange(next); + }; + + return ( +
+
+
+ --- + Frontmatter + YAML metadata · drives routing & tool access +
+
+ + +
+
+ + {!yamlMode ? ( +
+ {GROUPS.map((group) => ( + + ))} + +
+ ) : ( +
{yaml}
+ )} +
+ ); +} + +function isFieldSet(key: string, fm: Frontmatter): boolean { + const v = fm[key]; + if (v == null) return false; + if (Array.isArray(v)) return v.length > 0; + if (typeof v === 'string') return v.trim() !== ''; + if (typeof v === 'boolean') return v === true; + return false; +} + +function FrontmatterGroup({ + group, + frontmatter, + setField, +}: { + group: GroupDef; + frontmatter: Frontmatter; + setField: (key: string, value: string | string[] | boolean | null) => void; +}) { + const [open, setOpen] = useState(group.defaultOpen); + const isConditionalVisible = (f: FieldDef) => + f.type !== 'conditional' || (f.showWhen != null && frontmatter[f.showWhen.key] === f.showWhen.value); + const visibleFields = group.fields.filter(isConditionalVisible); + const pinned = !group.defaultOpen + ? visibleFields.filter((f) => isFieldSet(f.key, frontmatter)) + : []; + + return ( +
+ + {!open && pinned.length > 0 && ( +
+ {pinned.map((def) => ( + + ))} +
+ )} + {open && ( +
+ {visibleFields.map((def) => ( + + ))} +
+ )} +
+ ); +} + +function FrontmatterField({ + def, + frontmatter, + setField, +}: { + def: FieldDef; + frontmatter: Frontmatter; + setField: (key: string, value: string | string[] | boolean | null) => void; +}) { + const value = frontmatter[def.key]; + const arr: string[] = Array.isArray(value) ? value.map((v) => String(v ?? '')) : []; + const isArray = def.type === 'array'; + const isConditionalText = def.type === 'conditional'; + + return ( +
+ +
+ {def.type === 'enum' && def.options ? ( + setField(def.key, next || null)} + options={def.options.map((o) => ({ + value: o, + label: o || 'inherit', + }))} + /> + ) : def.type === 'bool' ? ( + setField(def.key, next ? true : null)} + label={value === true ? 'enabled' : 'disabled'} + /> + ) : isArray ? ( +
+ {arr.map((item, i) => ( + + { + const next = [...arr]; + next[i] = e.target.value; + setField(def.key, next); + }} + /> + + + ))} + +
+ ) : def.type === 'multiline' ? ( +