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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ openspec/

# Worktrees
.worktrees/
showcase/

video/node_modules/

Expand Down
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,32 @@
# Changelog

## [0.19.3] - 2026-04-18

### New Features

#### Web-Based Skill Editor

- **Edit skills directly in the Web UI** — the resource detail page now has an **Edit** action that opens a full in-browser editor, replacing the previous read-only view. Changes are saved back to the source `SKILL.md` through a new `POST /api/resources/:name/content` endpoint
```bash
skillshare ui # then open any skill and click Edit
```

- **Two-pane Markdown editor** — side-by-side textarea and live preview with synced scrolling. A mode toggle in the status bar switches between Edit / Split / Preview (`⌘P` cycles), and `⌘S` saves while `Esc` cancels. An outline drawer lets you jump to any heading, and the status bar shows token / word / line / file counts with a 5K-character budget warning

- **Structured frontmatter editor** — all 13 official SKILL.md fields are grouped into Identity / Invocation / Execution / Metadata sections with field-appropriate controls: switch toggles for booleans, segmented control for enums, and chip inputs for list-valued fields. A shared 1,536-character budget is enforced across `description` + `when_to_use`. A raw **YAML mode** is also available, and round-trips cleanly with the Fields view. Legacy root-level `targets:` is automatically migrated to `metadata.targets` on load

- **Diff review before save** — saving opens a side-by-side diff modal so you can confirm every change before writing to disk. The YAML serializer emits plain scalars when safe, so the diff no longer shows spurious quote wrapping for values containing `:`, `*`, `#`, or `"`

- **Open in local editor** — a new `POST /api/resources/:name/open` endpoint opens the skill file in your preferred local editor (via `$EDITOR`), useful when you prefer `vim` / VS Code over the browser editor

- **Targets visible in detail sidebar** — the resource detail sidebar now shows a **Targets** row when `metadata.targets` is set, so you can see at a glance which targets a skill is scoped to

#### Web UI Localization

- **11 languages in the Web UI** — every page is now fully translated. Pick your language from the language switcher in the top navigation bar; the preference is saved to your browser and auto-detected on first visit from `navigator.languages`
- Supported: English, 中文, 日本語, 한국어, Español, Français, Deutsch, فارسی, Português (BR), Bahasa Indonesia
- Persian (فارسی) automatically switches the layout to right-to-left

## [0.19.2] - 2026-04-14

### New Features
Expand Down
208 changes: 208 additions & 0 deletions internal/server/handler_open_in_editor.go
Original file line number Diff line number Diff line change
@@ -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)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The pickEditor function allows executing any binary found on the system's PATH if the requested editor is not a known alias. This is a security risk as it could allow arbitrary command execution if the API endpoint is exposed or triggered via a cross-site request (e.g., if the server lacks CSRF protection). It is safer to restrict the allowed editors to the knownEditors whitelist.

		return editorCandidate{}, "", fmt.Errorf("unsupported editor: %s", 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=<name>")
}

// 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:]
}
100 changes: 100 additions & 0 deletions internal/server/handler_open_in_editor_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading