diff --git a/.gitignore b/.gitignore index 43efc259..647cc846 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,7 @@ openspec/ # Worktrees .worktrees/ +showcase/ video/node_modules/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ac2e723..aa2f5e0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/internal/server/handler_open_in_editor.go b/internal/server/handler_open_in_editor.go new file mode 100644 index 00000000..91a1dde1 --- /dev/null +++ b/internal/server/handler_open_in_editor.go @@ -0,0 +1,205 @@ +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 — reject to prevent arbitrary binary execution. + 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=") +} + +// 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..1df75308 --- /dev/null +++ b/internal/server/handler_open_in_editor_test.go @@ -0,0 +1,102 @@ +package server + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestHandleOpenInEditor_UsesEchoAsEditor(t *testing.T) { + // Use auto-detect via $EDITOR so we don't need the binary in the whitelist. + t.Setenv("VISUAL", "") + t.Setenv("EDITOR", "true") + + s, src := newTestServer(t) + addSkill(t, src, "my-skill") + + body := openInEditorRequest{Editor: "auto"} + 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.Editor != "true" { + t.Errorf("expected editor=true, got %s", resp.Editor) + } +} + +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..2de39c71 --- /dev/null +++ b/internal/server/handler_skill_content.go @@ -0,0 +1,168 @@ +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 (or equal to) dir. +// Uses filepath.Rel for correctness on case-insensitive filesystems. +func withinDir(path, dir string) bool { + rel, err := filepath.Rel(filepath.Clean(dir), filepath.Clean(path)) + if err != nil { + return false + } + return rel == "." || (!strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel)) +} + +// 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/handler_sync_matrix.go b/internal/server/handler_sync_matrix.go index 4496083b..3ee1604e 100644 --- a/internal/server/handler_sync_matrix.go +++ b/internal/server/handler_sync_matrix.go @@ -10,11 +10,44 @@ import ( ) type syncMatrixEntry struct { - Skill string `json:"skill"` - Target string `json:"target"` - Status string `json:"status"` - Reason string `json:"reason"` - Kind string `json:"kind,omitempty"` + Skill string `json:"skill"` + Target string `json:"target"` + Status string `json:"status"` + Reason string `json:"reason"` + ReasonCode string `json:"reasonCode,omitempty"` + ReasonParams map[string]string `json:"reasonParams,omitempty"` + Kind string `json:"kind,omitempty"` +} + +func syncMatrixReason(status, reason string) (string, map[string]string) { + if reason == "symlink mode — filters not applicable" { + return "sync_matrix.symlink_filters_not_applicable", nil + } + switch status { + case "synced": + return "sync_matrix.synced", nil + case "excluded": + return "sync_matrix.excluded", map[string]string{"pattern": reason} + case "not_included": + return "sync_matrix.not_included", nil + case "skill_target_mismatch": + return "sync_matrix.skill_target_mismatch", nil + default: + return "", nil + } +} + +func newSyncMatrixEntry(skill, target, status, reason, kind string) syncMatrixEntry { + reasonCode, reasonParams := syncMatrixReason(status, reason) + return syncMatrixEntry{ + Skill: skill, + Target: target, + Status: status, + Reason: reason, + ReasonCode: reasonCode, + ReasonParams: reasonParams, + Kind: kind, + } } func (s *Server) handleSyncMatrix(w http.ResponseWriter, r *http.Request) { @@ -54,22 +87,12 @@ func (s *Server) handleSyncMatrix(w http.ResponseWriter, r *http.Request) { sc := target.SkillsConfig() if sc.Mode == "symlink" { for _, skill := range skills { - entries = append(entries, syncMatrixEntry{ - Skill: skill.FlatName, - Target: name, - Status: "na", - Reason: "symlink mode — filters not applicable", - }) + entries = append(entries, newSyncMatrixEntry(skill.FlatName, name, "na", "symlink mode — filters not applicable", "")) } } else { for _, skill := range skills { status, reason := ssync.ClassifySkillForTarget(skill.FlatName, skill.Targets, name, sc.Include, sc.Exclude) - entries = append(entries, syncMatrixEntry{ - Skill: skill.FlatName, - Target: name, - Status: status, - Reason: reason, - }) + entries = append(entries, newSyncMatrixEntry(skill.FlatName, name, status, reason, "")) } } // Agents — resolve path from user config or builtin defaults @@ -89,24 +112,12 @@ func (s *Server) handleSyncMatrix(w http.ResponseWriter, r *http.Request) { } if agentMode == "symlink" { for _, agent := range agents { - entries = append(entries, syncMatrixEntry{ - Skill: agent.FlatName, - Target: name, - Status: "na", - Reason: "symlink mode — filters not applicable", - Kind: "agent", - }) + entries = append(entries, newSyncMatrixEntry(agent.FlatName, name, "na", "symlink mode — filters not applicable", "agent")) } } else { for _, agent := range agents { status, reason := ssync.ClassifySkillForTarget(agent.FlatName, agent.Targets, name, ac.Include, ac.Exclude) - entries = append(entries, syncMatrixEntry{ - Skill: agent.FlatName, - Target: name, - Status: status, - Reason: reason, - Kind: "agent", - }) + entries = append(entries, newSyncMatrixEntry(agent.FlatName, name, status, reason, "agent")) } } } @@ -166,12 +177,7 @@ func (s *Server) handleSyncMatrixPreview(w http.ResponseWriter, r *http.Request) var entries []syncMatrixEntry for _, skill := range skills { status, reason := ssync.ClassifySkillForTarget(skill.FlatName, skill.Targets, body.Target, body.Include, body.Exclude) - entries = append(entries, syncMatrixEntry{ - Skill: skill.FlatName, - Target: body.Target, - Status: status, - Reason: reason, - }) + entries = append(entries, newSyncMatrixEntry(skill.FlatName, body.Target, status, reason, "")) } // Agents — resolve path from config or builtin defaults @@ -199,24 +205,12 @@ func (s *Server) handleSyncMatrixPreview(w http.ResponseWriter, r *http.Request) } if agentMode == "symlink" { for _, agent := range agents { - entries = append(entries, syncMatrixEntry{ - Skill: agent.FlatName, - Target: body.Target, - Status: "na", - Reason: "symlink mode — filters not applicable", - Kind: "agent", - }) + entries = append(entries, newSyncMatrixEntry(agent.FlatName, body.Target, "na", "symlink mode — filters not applicable", "agent")) } } else { for _, agent := range agents { status, reason := ssync.ClassifySkillForTarget(agent.FlatName, agent.Targets, body.Target, body.AgentInclude, body.AgentExclude) - entries = append(entries, syncMatrixEntry{ - Skill: agent.FlatName, - Target: body.Target, - Status: status, - Reason: reason, - Kind: "agent", - }) + entries = append(entries, newSyncMatrixEntry(agent.FlatName, body.Target, status, reason, "agent")) } } } diff --git a/internal/server/handler_sync_matrix_test.go b/internal/server/handler_sync_matrix_test.go index 951d84d7..c6ac6ff7 100644 --- a/internal/server/handler_sync_matrix_test.go +++ b/internal/server/handler_sync_matrix_test.go @@ -75,9 +75,11 @@ func TestHandleSyncMatrix_WithFilters(t *testing.T) { s.handler.ServeHTTP(rr, req) var resp struct { Entries []struct { - Skill string `json:"skill"` - Status string `json:"status"` - Reason string `json:"reason"` + Skill string `json:"skill"` + Status string `json:"status"` + Reason string `json:"reason"` + ReasonCode string `json:"reasonCode"` + ReasonParams map[string]string `json:"reasonParams"` } `json:"entries"` } json.Unmarshal(rr.Body.Bytes(), &resp) @@ -86,9 +88,15 @@ func TestHandleSyncMatrix_WithFilters(t *testing.T) { } statusMap := map[string]string{} reasonMap := map[string]string{} + reasonCodeMap := map[string]string{} + reasonPatternMap := map[string]string{} for _, e := range resp.Entries { statusMap[e.Skill] = e.Status reasonMap[e.Skill] = e.Reason + reasonCodeMap[e.Skill] = e.ReasonCode + if e.ReasonParams != nil { + reasonPatternMap[e.Skill] = e.ReasonParams["pattern"] + } } if statusMap["frontend-design"] != "synced" { t.Errorf("frontend-design: expected synced, got %q", statusMap["frontend-design"]) @@ -99,6 +107,12 @@ func TestHandleSyncMatrix_WithFilters(t *testing.T) { if reasonMap["backend-api"] != "backend*" { t.Errorf("backend-api reason: expected 'backend*', got %q", reasonMap["backend-api"]) } + if reasonCodeMap["backend-api"] != "sync_matrix.excluded" { + t.Errorf("backend-api reasonCode: expected sync_matrix.excluded, got %q", reasonCodeMap["backend-api"]) + } + if reasonPatternMap["backend-api"] != "backend*" { + t.Errorf("backend-api pattern param: expected backend*, got %q", reasonPatternMap["backend-api"]) + } } func TestHandleSyncMatrix_TargetQueryParam(t *testing.T) { diff --git a/internal/server/middleware.go b/internal/server/middleware.go index 5b281af3..61103b51 100644 --- a/internal/server/middleware.go +++ b/internal/server/middleware.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "net/http" + "strings" "sync" "time" ) @@ -16,9 +17,54 @@ func writeJSON(w http.ResponseWriter, data any) { // writeError writes a JSON error response func writeError(w http.ResponseWriter, code int, msg string) { + writeCodedError(w, code, defaultErrorCode(code, msg), msg, nil) +} + +// writeCodedError writes a JSON error response with a stable machine-readable +// code while preserving the legacy string `error` field for existing callers. +func writeCodedError(w http.ResponseWriter, status int, errCode, msg string, params map[string]string) { w.Header().Set("Content-Type", "application/json") - w.WriteHeader(code) - json.NewEncoder(w).Encode(map[string]string{"error": msg}) + w.WriteHeader(status) + body := map[string]any{ + "error": msg, + "error_code": errCode, + "error_params": params, + } + if params == nil { + delete(body, "error_params") + } + json.NewEncoder(w).Encode(body) +} + +func defaultErrorCode(status int, msg string) string { + lower := strings.ToLower(msg) + switch { + case strings.Contains(lower, "not found"): + return "not_found" + case strings.Contains(lower, "invalid"): + return "validation" + case strings.Contains(lower, "required"): + return "validation" + case strings.Contains(lower, "conflict") || strings.Contains(lower, "already exists"): + return "conflict" + } + switch status { + case http.StatusBadRequest: + return "bad_request" + case http.StatusUnauthorized, http.StatusForbidden: + return "unauthorized" + case http.StatusNotFound: + return "not_found" + case http.StatusConflict: + return "conflict" + case http.StatusUnprocessableEntity: + return "validation" + default: + if status >= 500 { + return "internal" + } + return "generic" + } } // sendSSE writes a single Server-Sent Event frame and flushes. diff --git a/internal/server/middleware_test.go b/internal/server/middleware_test.go new file mode 100644 index 00000000..22a4da2d --- /dev/null +++ b/internal/server/middleware_test.go @@ -0,0 +1,53 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestWriteErrorIncludesStableCodeAndLegacyMessage(t *testing.T) { + rr := httptest.NewRecorder() + + writeError(rr, http.StatusNotFound, "target not found: claude") + + if rr.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", rr.Code) + } + + var body struct { + Error string `json:"error"` + ErrorCode string `json:"error_code"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil { + t.Fatalf("decode response: %v", err) + } + if body.Error != "target not found: claude" { + t.Fatalf("legacy error mismatch: %q", body.Error) + } + if body.ErrorCode != "not_found" { + t.Fatalf("error code mismatch: %q", body.ErrorCode) + } +} + +func TestWriteCodedErrorIncludesParams(t *testing.T) { + rr := httptest.NewRecorder() + + writeCodedError(rr, http.StatusNotFound, "target.not_found", "target not found: claude", map[string]string{"target": "claude"}) + + var body struct { + Error string `json:"error"` + ErrorCode string `json:"error_code"` + ErrorParams map[string]string `json:"error_params"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil { + t.Fatalf("decode response: %v", err) + } + if body.ErrorCode != "target.not_found" { + t.Fatalf("error code mismatch: %q", body.ErrorCode) + } + if body.ErrorParams["target"] != "claude" { + t.Fatalf("target param mismatch: %#v", body.ErrorParams) + } +} 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/skills/skillshare/SKILL.md b/skills/skillshare/SKILL.md index 9a39d68a..80874f3d 100644 --- a/skills/skillshare/SKILL.md +++ b/skills/skillshare/SKILL.md @@ -15,7 +15,7 @@ description: | `.agentignore` and `enable`/`disable` for per-agent toggles. argument-hint: "[command] [target] [--json] [--dry-run] [-p|-g]" metadata: - version: v0.19.2 + version: v0.19.3 --- # Skillshare CLI diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 9fbaa738..12d81da9 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -6,6 +6,7 @@ import { queryClient } from './lib/queryClient'; import { ToastProvider } from './components/Toast'; import { ThemeProvider } from './context/ThemeContext'; import { AppProvider } from './context/AppContext'; +import { I18nProvider } from './i18n'; import { PageSkeleton } from './components/Skeleton'; import { ErrorBoundary } from './components/ErrorBoundary'; import Layout from './components/Layout'; @@ -43,44 +44,46 @@ export default function App() { return ( - - - - - - - - - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - - - - - + + + + + + + + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + + + + diff --git a/ui/src/api/client.test.ts b/ui/src/api/client.test.ts new file mode 100644 index 00000000..bf4544ca --- /dev/null +++ b/ui/src/api/client.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { parseApiErrorPayload } from './client'; + +describe('parseApiErrorPayload', () => { + it('parses the new structured error format', () => { + expect(parseApiErrorPayload({ + error: { + code: 'target.not_found', + message: 'target not found: claude', + params: { target: 'claude' }, + }, + }, 404, 'Not Found')).toEqual({ + code: 'target.not_found', + message: 'target not found: claude', + params: { target: 'claude' }, + }); + }); + + it('parses legacy string errors with sidecar codes', () => { + expect(parseApiErrorPayload({ + error: 'target not found: claude', + error_code: 'target.not_found', + error_params: { target: 'claude' }, + }, 404, 'Not Found')).toEqual({ + code: 'target.not_found', + message: 'target not found: claude', + params: { target: 'claude' }, + }); + }); + + it('falls back to status-derived codes for legacy errors', () => { + expect(parseApiErrorPayload({ error: 'missing' }, 404, 'Not Found')).toEqual({ + code: 'not_found', + message: 'missing', + params: undefined, + }); + }); +}); diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index 9ed58a13..07aadfbd 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -4,12 +4,72 @@ const BASE = BASE_PATH + '/api'; export class ApiError extends Error { status: number; - constructor(status: number, message: string) { + code?: string; + params?: Record; + fallbackMessage: string; + + constructor( + status: number, + message: string, + opts?: { code?: string; params?: Record; fallbackMessage?: string }, + ) { super(message); this.status = status; + this.code = opts?.code; + this.params = opts?.params; + this.fallbackMessage = opts?.fallbackMessage ?? message; } } +interface ParsedApiError { + code?: string; + message: string; + params?: Record; +} + +function defaultErrorCode(status: number): string { + switch (status) { + case 400: + return 'bad_request'; + case 401: + case 403: + return 'unauthorized'; + case 404: + return 'not_found'; + case 409: + return 'conflict'; + case 422: + return 'validation'; + default: + return status >= 500 ? 'internal' : 'generic'; + } +} + +export function parseApiErrorPayload(data: any, status: number, statusText: string): ParsedApiError { + const rawError = data?.error; + if (rawError && typeof rawError === 'object') { + const code = typeof rawError.code === 'string' ? rawError.code : defaultErrorCode(status); + const message = typeof rawError.message === 'string' ? rawError.message : statusText || 'Request failed'; + const params = rawError.params && typeof rawError.params === 'object' + ? rawError.params as Record + : undefined; + return { code, message, params }; + } + + const message = typeof rawError === 'string' ? rawError : statusText || 'Request failed'; + const code = typeof data?.error_code === 'string' + ? data.error_code + : typeof data?.code === 'string' + ? data.code + : defaultErrorCode(status); + const params = data?.error_params && typeof data.error_params === 'object' + ? data.error_params as Record + : data?.params && typeof data.params === 'object' + ? data.params as Record + : undefined; + return { code, message, params }; +} + export async function apiFetch(path: string, init?: RequestInit): Promise { let res: Response; try { @@ -18,20 +78,32 @@ export async function apiFetch(path: string, init?: RequestInit): Promise ...init, }); } catch { - throw new ApiError(0, 'Server connection lost — try restarting with "skillshare ui".'); + throw new ApiError(0, 'Server connection lost - try restarting with "skillshare ui".', { + code: 'connection_lost', + }); } const text = await res.text(); if (!text) { - throw new ApiError(res.status || 502, 'Empty response from server (request may have timed out)'); + throw new ApiError(res.status || 502, 'Empty response from server (request may have timed out)', { + code: 'empty_response', + }); } let data: any; try { data = JSON.parse(text); } catch { - throw new ApiError(res.status || 502, `Invalid JSON response: ${text.slice(0, 200)}`); + throw new ApiError(res.status || 502, `Invalid JSON response: ${text.slice(0, 200)}`, { + code: 'invalid_json', + params: { snippet: text.slice(0, 200) }, + }); } if (!res.ok) { - throw new ApiError(res.status, data.error ?? res.statusText); + const parsed = parseApiErrorPayload(data, res.status, res.statusText); + throw new ApiError(res.status, parsed.message, { + code: parsed.code, + params: parsed.params, + fallbackMessage: parsed.message, + }); } return data as T; } @@ -117,6 +189,8 @@ export interface SyncMatrixEntry { target: string; status: 'synced' | 'excluded' | 'not_included' | 'skill_target_mismatch' | 'na'; reason: string; + reasonCode?: string; + reasonParams?: Record; kind?: 'skill' | 'agent'; } @@ -323,6 +397,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/ConfirmDialog.tsx b/ui/src/components/ConfirmDialog.tsx index 2f9b3654..e4602735 100644 --- a/ui/src/components/ConfirmDialog.tsx +++ b/ui/src/components/ConfirmDialog.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from 'react'; import Button from './Button'; import DialogShell from './DialogShell'; +import { useT } from '../i18n'; interface ConfirmDialogProps { open: boolean; @@ -21,12 +22,15 @@ export default function ConfirmDialog({ onCancel, title, message, - confirmText = 'Confirm', - cancelText = 'Cancel', + confirmText, + cancelText, variant = 'default', loading = false, wide = false, }: ConfirmDialogProps) { + const t = useT(); + const resolvedCancelText = cancelText ?? t('common.cancel'); + const resolvedConfirmText = confirmText ?? t('common.confirm'); return (
- {cancelText && ( + {resolvedCancelText && ( )}
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/FileViewerModal.tsx b/ui/src/components/FileViewerModal.tsx index ada5f84a..bed5e274 100644 --- a/ui/src/components/FileViewerModal.tsx +++ b/ui/src/components/FileViewerModal.tsx @@ -14,6 +14,7 @@ import Spinner from './Spinner'; import DialogShell from './DialogShell'; import { api, type SkillFileContent } from '../api/client'; import { handTheme } from '../lib/codemirror-theme'; +import { useT } from '../i18n'; interface FileViewerModalProps { skillName: string; @@ -23,6 +24,7 @@ interface FileViewerModalProps { } export default function FileViewerModal({ skillName, filepath, sourcePath, onClose }: FileViewerModalProps) { + const t = useT(); const fullPath = sourcePath ? `${sourcePath}/${filepath}` : filepath; const [data, setData] = useState(null); const [loading, setLoading] = useState(true); @@ -64,13 +66,13 @@ export default function FileViewerModal({ skillName, filepath, sourcePath, onClo {filepath} } - label="Close" + label={t('common.close')} size="md" onClick={onClose} className="shrink-0 ml-2" diff --git a/ui/src/components/HubManagerModal.tsx b/ui/src/components/HubManagerModal.tsx index c6646b9d..7e48c473 100644 --- a/ui/src/components/HubManagerModal.tsx +++ b/ui/src/components/HubManagerModal.tsx @@ -5,6 +5,7 @@ import IconButton from './IconButton'; import DialogShell from './DialogShell'; import { Input } from './Input'; import { radius } from '../design'; +import { useT } from '../i18n'; export interface SavedHub { label: string; @@ -29,6 +30,7 @@ export default function HubManagerModal({ onSave, onClose, }: HubManagerModalProps) { + const t = useT(); const [localHubs, setLocalHubs] = useState([]); const [newLabel, setNewLabel] = useState(''); const [newURL, setNewURL] = useState(''); @@ -63,11 +65,11 @@ export default function HubManagerModal({ const handleAdd = () => { const url = normalizeURL(newURL); if (!url) { - setError('URL is required'); + setError(t('hubManager.error.urlRequired')); return; } if (localHubs.some((h) => normalizeURL(h.url) === url)) { - setError('This hub URL already exists'); + setError(t('hubManager.error.urlExists')); return; } const label = newLabel.trim() || url.split('/').pop() || 'Untitled'; @@ -96,7 +98,7 @@ export default function HubManagerModal({

- Manage Hubs + {t('hubManager.title')}

@@ -118,7 +120,7 @@ export default function HubManagerModal({ {hub.builtIn ? ( - Built-in + {t('hubManager.builtIn')} ) : confirmDelete === hub.url ? (
) : ( } - label="Remove hub" + label={t('hubManager.removeHub')} size="sm" variant="ghost" onClick={() => handleDelete(hub.url)} @@ -151,25 +153,25 @@ export default function HubManagerModal({ ) : (

- No hubs configured yet. + {t('hubManager.noHubs')}

)} {/* Add hub form */}

- Add Hub + {t('hubManager.addHub')}

setNewLabel(e.target.value)} /> { setNewURL(e.target.value); @@ -182,15 +184,15 @@ export default function HubManagerModal({

{error}

)}

- Enter a URL or local path to a skillshare-hub.json file. + {t('hubManager.hubHint')}

diff --git a/ui/src/components/InstallForm.tsx b/ui/src/components/InstallForm.tsx index 96feafa7..d5f3393c 100644 --- a/ui/src/components/InstallForm.tsx +++ b/ui/src/components/InstallForm.tsx @@ -13,6 +13,7 @@ import { queryKeys } from '../lib/queryKeys'; import { clearAuditCache } from '../lib/auditCache'; import { radius } from '../design'; import { formatSkillDisplayName } from '../lib/resourceNames'; +import { useT } from '../i18n'; interface InstallFormProps { /** Called after a successful install with the result */ @@ -97,6 +98,7 @@ export default function InstallForm({ collapsible = true, className = '', }: InstallFormProps) { + const t = useT(); const [open, setOpen] = useState(defaultOpen); const [source, setSource] = useState(''); const [name, setName] = useState(''); @@ -152,8 +154,7 @@ export default function InstallForm({ /** Handle install result: show warning dialog if warnings exist, otherwise just toast */ const handleResult = useCallback( (res: InstallResult, label?: string) => { - const prefix = label ? `${label}: ` : ''; - toast(`${prefix}Installed (${res.action})`, 'success'); + toast(label ? t('installForm.toast.installedLabel', { label }) : t('installForm.toast.installed'), 'success'); if (res.warnings && res.warnings.length > 0) { setWarningDialog(res.warnings); } @@ -212,7 +213,7 @@ export default function InstallForm({ if (item.warnings?.length) allWarnings.push(...item.warnings.map((w) => `${item.name}: ${w}`)); } if (allErrors.length > 0) { - toast(`${allErrors.length} failed: ${allErrors.join('; ')}`, 'error'); + toast(t('common.nFailed', { count: allErrors.length, details: allErrors.join('; ') }), 'error'); } if (allWarnings.length > 0) setWarningDialog(allWarnings); resetForm(); @@ -330,7 +331,7 @@ export default function InstallForm({ if (item.warnings?.length) allWarnings.push(...item.warnings.map((w) => `${item.name}: ${w}`)); } if (allErrors.length > 0) { - toast(`${allErrors.length} failed: ${allErrors.join('; ')}`, 'error'); + toast(t('common.nFailed', { count: allErrors.length, details: allErrors.join('; ') }), 'error'); } if (installed > 0) { const variant = auditBlockedSkills.length > 0 ? 'warning' : 'success'; @@ -399,7 +400,7 @@ export default function InstallForm({ if (item.warnings?.length) allWarnings.push(...item.warnings.map((w) => `${item.name}: ${w}`)); } if (allErrors.length > 0) { - toast(`${allErrors.length} failed: ${allErrors.join('; ')}`, 'error'); + toast(t('common.nFailed', { count: allErrors.length, details: allErrors.join('; ') }), 'error'); } // Only show summary + close picker when at least one skill installed @@ -455,18 +456,18 @@ export default function InstallForm({
setSource(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleInstall()} /> {isGitSource(source) && ( setBranch(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleInstall()} @@ -475,7 +476,7 @@ export default function InstallForm({

e.g. owner/repo, https://github.com/…, or ~/local/path - {isGitSource(source) && <> · Leave branch empty for remote default} + {isGitSource(source) && <> · {t('installForm.sourceHintBranch')}}

@@ -483,18 +484,18 @@ export default function InstallForm({
setName(e.target.value)} /> -

Only applies to single resource install

+

{t('installForm.customNameHint')}

setInto(e.target.value)} /> @@ -504,23 +505,23 @@ export default function InstallForm({

- Track keeps the git repo linked for updates · Force overwrites existing resources · Skip audit bypasses security scan + {t('installForm.optionsHint')}

@@ -533,7 +534,7 @@ export default function InstallForm({ loading={installing} > - Install + {t('installForm.installButton')}
@@ -554,11 +555,11 @@ export default function InstallForm({ const kindSelectorDialog = (

- This repository contains both skills and agents. What would you like to install? + {t('installForm.kindSelector.message')}

} confirmText="" - cancelText="Cancel" + cancelText={t('installForm.kindSelector.cancelText')} onConfirm={() => setShowKindSelector(false)} onCancel={() => setShowKindSelector(false)} /> @@ -605,20 +606,20 @@ export default function InstallForm({ open={!!auditDialog} variant="danger" wide - title="Security Threats Detected" + title={t('installForm.audit.title')} message={
- Critical issues found during security audit + {t('installForm.audit.causeText')}
- {auditFindings.length} {auditFindings.length === 1 ? 'finding' : 'findings'}: + {auditFindings.length === 1 ? t('installForm.audit.findings', { count: auditFindings.length }) : t('installForm.audit.findingsPlural', { count: auditFindings.length })}: {filterBadge('CRITICAL', auditCounts.CRITICAL, 'danger', 'Critical')} {filterBadge('HIGH', auditCounts.HIGH, 'warning', 'High')} {filterBadge('MEDIUM', auditCounts.MEDIUM, 'default', 'Medium')} {severityFilter && ( - — showing {filteredAudit.length} + {t('installForm.audit.showingCount', { count: filteredAudit.length })} )}
@@ -651,12 +652,12 @@ export default function InstallForm({ })}

- Force install will bypass the security check. Proceed with caution. + {t('installForm.audit.forceWarning')}

} - confirmText="Force Install" - cancelText="Cancel" + confirmText={t('installForm.audit.forceInstall')} + cancelText={t('installForm.audit.cancelText')} onConfirm={handleAuditForce} onCancel={() => { setAuditDialog(null); setSeverityFilter(null); }} loading={auditForcing} @@ -668,20 +669,20 @@ export default function InstallForm({ open={!!warningDialog} variant="default" wide - title="Security Warnings" + title={t('installForm.warnings.title')} message={
- Resource installed with audit warnings + {t('installForm.warnings.causeText')}
- {warningFindings.length} {warningFindings.length === 1 ? 'warning' : 'warnings'}: + {warningFindings.length === 1 ? t('installForm.warnings.warning', { count: warningFindings.length }) : t('installForm.warnings.warningPlural', { count: warningFindings.length })}: {filterBadge('CRITICAL', warningCounts.CRITICAL, 'danger', 'Critical')} {filterBadge('HIGH', warningCounts.HIGH, 'warning', 'High')} {filterBadge('MEDIUM', warningCounts.MEDIUM, 'default', 'Medium')} {severityFilter && ( - — showing {filteredWarnings.length} + {t('installForm.audit.showingCount', { count: filteredWarnings.length })} )}
@@ -715,7 +716,7 @@ export default function InstallForm({
} - confirmText="OK" + confirmText={t('installForm.warnings.okText')} cancelText="" onConfirm={() => { setWarningDialog(null); setSeverityFilter(null); }} onCancel={() => { setWarningDialog(null); setSeverityFilter(null); }} @@ -746,7 +747,7 @@ export default function InstallForm({ }} > - Install from URL / Path + {t('installForm.openLabel')} {open ? : } {open && formContent} diff --git a/ui/src/components/KeyboardShortcutsModal.tsx b/ui/src/components/KeyboardShortcutsModal.tsx index bbd063a3..076aa3e5 100644 --- a/ui/src/components/KeyboardShortcutsModal.tsx +++ b/ui/src/components/KeyboardShortcutsModal.tsx @@ -2,6 +2,7 @@ import { X, Keyboard } from 'lucide-react'; import { SHORTCUT_ENTRIES, isMacOS } from '../hooks/useGlobalShortcuts'; import { radius } from '../design'; import DialogShell from './DialogShell'; +import { useT } from '../i18n'; interface KeyboardShortcutsModalProps { open: boolean; @@ -9,6 +10,7 @@ interface KeyboardShortcutsModalProps { } export default function KeyboardShortcutsModal({ open, onClose }: KeyboardShortcutsModalProps) { + const t = useT(); return ( {/* Header */} @@ -16,13 +18,13 @@ export default function KeyboardShortcutsModal({ open, onClose }: KeyboardShortc

- Keyboard Shortcuts + {t('shortcuts.title')}

@@ -37,7 +39,7 @@ export default function KeyboardShortcutsModal({ open, onClose }: KeyboardShortc style={{ borderRadius: radius.sm }} > - {entry.label} + {t(entry.labelKey)} @@ -46,7 +48,7 @@ export default function KeyboardShortcutsModal({ open, onClose }: KeyboardShortc {/* Footer hint */}

- Shortcuts are disabled when typing in inputs. + {t('shortcuts.disabledInInputs')}

); @@ -55,6 +57,7 @@ export default function KeyboardShortcutsModal({ open, onClose }: KeyboardShortc /** Renders key combo like "g d" or "Mod+S" as individual key badges */ function ShortcutKeys({ keys }: { keys: string }) { const mac = isMacOS(); + const t = useT(); // Handle modifier shortcuts like "Mod+S" if (keys.startsWith('Mod+')) { @@ -76,7 +79,7 @@ function ShortcutKeys({ keys }: { keys: string }) { {i < parts.length - 1 && ( - then + {t('common.then')} )} ))} diff --git a/ui/src/components/LanguagePopover.test.tsx b/ui/src/components/LanguagePopover.test.tsx new file mode 100644 index 00000000..05e4cbdd --- /dev/null +++ b/ui/src/components/LanguagePopover.test.tsx @@ -0,0 +1,41 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it } from 'vitest'; +import LanguagePopover from './LanguagePopover'; +import { I18nProvider, LOCALE_STORAGE_KEY } from '../i18n'; + +function renderLanguagePopover() { + return render( + + + , + ); +} + +describe('LanguagePopover', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('persists locale changes and updates visible copy', async () => { + const user = userEvent.setup(); + renderLanguagePopover(); + + await user.click(screen.getByRole('button', { name: 'Language' })); + await user.click(screen.getByRole('radio', { name: /繁體中文/ })); + + expect(localStorage.getItem(LOCALE_STORAGE_KEY)).toBe('zh-TW'); + expect(screen.getByRole('button', { name: '語言' })).toBeInTheDocument(); + }); + + it('puts simplified Chinese first for zh-CN users', async () => { + const user = userEvent.setup(); + localStorage.setItem(LOCALE_STORAGE_KEY, 'zh-CN'); + renderLanguagePopover(); + + await user.click(screen.getByRole('button', { name: '语言' })); + + const options = screen.getAllByRole('radio'); + expect(options[0]).toHaveTextContent('简体中文'); + }); +}); diff --git a/ui/src/components/LanguagePopover.tsx b/ui/src/components/LanguagePopover.tsx new file mode 100644 index 00000000..edf6b5f9 --- /dev/null +++ b/ui/src/components/LanguagePopover.tsx @@ -0,0 +1,107 @@ +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { Languages } from 'lucide-react'; +import { shadows } from '../design'; +import { supportedLocales, useI18n, type Locale } from '../i18n'; + +export default function LanguagePopover() { + const { locale, setLocale, t } = useI18n(); + const [open, setOpen] = useState(false); + const [dropUp, setDropUp] = useState(true); + const containerRef = useRef(null); + const panelRef = useRef(null); + const triggerRef = useRef(null); + const localeOptions = useMemo(() => { + if (locale !== 'zh-CN') return supportedLocales; + const simplified = supportedLocales.find((entry) => entry.code === 'zh-CN'); + return simplified + ? [simplified, ...supportedLocales.filter((entry) => entry.code !== 'zh-CN')] + : supportedLocales; + }, [locale]); + + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [open]); + + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') setOpen(false); + }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, [open]); + + useLayoutEffect(() => { + if (!open || !containerRef.current) return; + const rect = containerRef.current.getBoundingClientRect(); + const panelHeight = 320; + setDropUp(rect.top > panelHeight); + }, [open]); + + useEffect(() => { + if (!open || !panelRef.current) return; + const selected = panelRef.current.querySelector('[aria-checked="true"]') as HTMLElement | null; + selected?.focus(); + }, [open]); + + const selectLocale = (nextLocale: Locale) => { + setLocale(nextLocale); + setOpen(false); + triggerRef.current?.focus(); + }; + + return ( +
+ + + {open && ( +
+ {localeOptions.map((entry) => ( + + ))} +
+ )} +
+ ); +} diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index c5782b51..0e6d5cb7 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -31,67 +31,70 @@ import { useGlobalShortcuts } from '../hooks/useGlobalShortcuts'; import KeyboardShortcutsModal from './KeyboardShortcutsModal'; import ShortcutHUD from './ShortcutHUD'; import ThemePopover from './ThemePopover'; +import LanguagePopover from './LanguagePopover'; import { useTour } from './tour'; import UpdateDialog from './UpdateDialog'; +import { useT } from '../i18n'; interface NavItem { to: string; icon: React.ElementType; - label: string; + labelKey: string; hideInProject?: boolean; } interface NavGroup { - label?: string; + labelKey?: string; items: NavItem[]; } const navGroups: NavGroup[] = [ { items: [ - { to: '/', icon: LayoutDashboard, label: 'Dashboard' }, + { to: '/', icon: LayoutDashboard, labelKey: 'layout.nav.dashboard' }, ], }, { - label: 'MANAGE', + labelKey: 'layout.group.manage', items: [ - { to: '/resources', icon: Layers, label: 'Resources' }, - { to: '/extras', icon: FolderPlus, label: 'Extras' }, - { to: '/targets', icon: Target, label: 'Targets' }, - { to: '/search', icon: Search, label: 'Search' }, + { to: '/resources', icon: Layers, labelKey: 'layout.nav.resources' }, + { to: '/extras', icon: FolderPlus, labelKey: 'layout.nav.extras' }, + { to: '/targets', icon: Target, labelKey: 'layout.nav.targets' }, + { to: '/search', icon: Search, labelKey: 'layout.nav.search' }, ], }, { - label: 'OPERATIONS', + labelKey: 'layout.group.operations', items: [ - { to: '/sync', icon: RefreshCw, label: 'Sync' }, - { to: '/collect', icon: ArrowDownToLine, label: 'Collect' }, - { to: '/install', icon: Download, label: 'Install' }, - { to: '/update', icon: ArrowUpCircle, label: 'Update' }, - { to: '/uninstall', icon: Trash2, label: 'Uninstall' }, + { to: '/sync', icon: RefreshCw, labelKey: 'layout.nav.sync' }, + { to: '/collect', icon: ArrowDownToLine, labelKey: 'layout.nav.collect' }, + { to: '/install', icon: Download, labelKey: 'layout.nav.install' }, + { to: '/update', icon: ArrowUpCircle, labelKey: 'layout.nav.update' }, + { to: '/uninstall', icon: Trash2, labelKey: 'layout.nav.uninstall' }, ], }, { - label: 'SECURITY & MAINTENANCE', + labelKey: 'layout.group.securityMaintenance', items: [ - { to: '/audit', icon: ShieldCheck, label: 'Audit' }, - { to: '/analyze', icon: BarChart3, label: 'Analyze' }, - { to: '/git', icon: GitBranch, label: 'Git Sync', hideInProject: true }, - { to: '/backup', icon: Archive, label: 'Backup', hideInProject: true }, - { to: '/trash', icon: Trash2, label: 'Trash' }, + { to: '/audit', icon: ShieldCheck, labelKey: 'layout.nav.audit' }, + { to: '/analyze', icon: BarChart3, labelKey: 'layout.nav.analyze' }, + { to: '/git', icon: GitBranch, labelKey: 'layout.nav.gitSync', hideInProject: true }, + { to: '/backup', icon: Archive, labelKey: 'layout.nav.backup', hideInProject: true }, + { to: '/trash', icon: Trash2, labelKey: 'layout.nav.trash' }, ], }, { - label: 'SYSTEM', + labelKey: 'layout.group.system', items: [ - { to: '/log', icon: ScrollText, label: 'Log' }, - { to: '/config', icon: Settings, label: 'Config' }, - { to: '/doctor', icon: Stethoscope, label: 'Health Check' }, + { to: '/log', icon: ScrollText, labelKey: 'layout.nav.log' }, + { to: '/config', icon: Settings, labelKey: 'layout.nav.config' }, + { to: '/doctor', icon: Stethoscope, labelKey: 'layout.nav.healthCheck' }, ], }, ]; export default function Layout() { + const t = useT(); const [mobileOpen, setMobileOpen] = useState(false); const [shortcutsOpen, setShortcutsOpen] = useState(false); const [toolsOpen, setToolsOpen] = useState(() => { @@ -125,7 +128,7 @@ export default function Layout() { onClick={() => setMobileOpen(!mobileOpen)} className="fixed top-4 left-4 z-50 md:hidden w-10 h-10 flex items-center justify-center bg-surface border-2 border-pencil cursor-pointer" style={{ borderRadius: radius.sm }} - aria-label={mobileOpen ? 'Close menu' : 'Open menu'} + aria-label={mobileOpen ? t('layout.mobile.closeMenu') : t('layout.mobile.openMenu')} > {mobileOpen ? : } @@ -154,20 +157,20 @@ export default function Layout() { className="text-2xl font-bold text-pencil tracking-wide" > - skillshare + {t('app.name')}

- Web Dashboard + {t('app.subtitle')}

{isProjectMode && ( - Project + {t('app.project')} )}
@@ -177,12 +180,12 @@ export default function Layout() {