From 1aace468d05409d9526b4754d3ef0512f5627df0 Mon Sep 17 00:00:00 2001
From: wdh-home <243823965@qq.com>
Date: Wed, 13 May 2026 16:07:36 +0800
Subject: [PATCH] docs: add ai skill manager implementation plan
---
.../plans/2026-05-13-ai-skill-manager.md | 1244 +++++++++++++++++
1 file changed, 1244 insertions(+)
create mode 100644 docs/superpowers/plans/2026-05-13-ai-skill-manager.md
diff --git a/docs/superpowers/plans/2026-05-13-ai-skill-manager.md b/docs/superpowers/plans/2026-05-13-ai-skill-manager.md
new file mode 100644
index 0000000..276007d
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-13-ai-skill-manager.md
@@ -0,0 +1,1244 @@
+# AI Skill Manager Windows Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Build a Wails Windows desktop application that discovers Gitea organization repositories containing root `SKILL.md`, clones them locally, manages updates, and installs them into Codex and Claude through Windows Junctions.
+
+**Architecture:** Use a Wails v2 app with a Go backend and React + TypeScript frontend. The backend owns all file, credential, Git, Gitea, Junction, and update behavior; the frontend is a three-tab management UI that calls Wails-generated bindings.
+
+**Tech Stack:** Go 1.26, Wails 2.12, React + Vite + TypeScript, Windows Credential Manager through `github.com/zalando/go-keyring`, system `git`, Windows Junctions via `cmd.exe /c mklink /J`.
+
+---
+
+## Scope Notes
+
+- Do not use git worktree.
+- Scaffold in the current repository. If Wails refuses to initialise into a non-empty directory, initialise into `.tmp-wails-scaffold`, copy generated project files into the repository root, and delete `.tmp-wails-scaffold`.
+- Do not store Gitea passwords or tokens in JSON files, Git remotes, logs, or frontend state returned after save.
+- Treat one repository as one skill.
+- A repository is valid only when its root contains `SKILL.md`.
+- Install means create a Junction, not copy the skill directory.
+
+## File Structure
+
+Create or modify these files:
+
+- `main.go` - Wails application entrypoint.
+- `app.go` - Wails-facing facade methods used by the frontend.
+- `internal/domain/types.go` - shared Go request/response/state types.
+- `internal/apperrors/errors.go` - user-facing error helpers.
+- `internal/config/config.go` - config loading, defaults, validation, and JSON persistence.
+- `internal/config/credentials.go` - Credential Manager adapter using `go-keyring`.
+- `internal/gitea/client.go` - Gitea API client.
+- `internal/gitops/git.go` - system git wrapper with non-persistent askpass credentials.
+- `internal/skillstore/store.go` - state file and local repo path management.
+- `internal/targets/targets.go` - target app definitions and install-state orchestration.
+- `internal/targets/junction_windows.go` - Windows Junction create/read/remove operations.
+- `internal/service/service.go` - application service combining config, Gitea, git, store, and targets.
+- `internal/service/updater.go` - startup and interval update runner.
+- `internal/*/*_test.go` - unit tests for backend modules.
+- `frontend/src/App.tsx` - three-tab UI.
+- `frontend/src/App.css` - management UI styles.
+- `frontend/src/api.ts` - typed wrapper over Wails bindings.
+- `frontend/src/types.ts` - frontend types mirrored from backend JSON.
+- `frontend/src/main.tsx` - React entrypoint.
+- `wails.json`, `go.mod`, `package.json`, and template files generated by Wails.
+
+## Task 1: Scaffold Wails React TypeScript App
+
+**Files:**
+- Create: `main.go`
+- Create: `app.go`
+- Create: `frontend/src/App.tsx`
+- Create: `frontend/src/main.tsx`
+- Create: `frontend/src/App.css`
+- Create: `wails.json`
+- Create: `go.mod`
+- Create: `package.json`
+- Modify: generated Wails files as needed
+
+- [ ] **Step 1: Verify clean status**
+
+Run:
+
+```powershell
+git status --short
+```
+
+Expected: no uncommitted files except this plan if it has not been committed yet.
+
+- [ ] **Step 2: Create Wails scaffold**
+
+Run:
+
+```powershell
+wails init -n sgg-ai-skill-manager -t react-ts -d .
+```
+
+Expected: Wails project files are created in the repository root. If Wails refuses because the directory is not empty, run:
+
+```powershell
+wails init -n sgg-ai-skill-manager -t react-ts -d .tmp-wails-scaffold
+Copy-Item -Path .tmp-wails-scaffold\* -Destination . -Recurse -Force
+Remove-Item -LiteralPath .tmp-wails-scaffold -Recurse -Force
+```
+
+- [ ] **Step 3: Add frontend icon dependency**
+
+Run:
+
+```powershell
+npm --prefix frontend install lucide-react
+```
+
+Expected: `frontend/package.json` contains `lucide-react`.
+
+- [ ] **Step 4: Add backend credential dependency**
+
+Run:
+
+```powershell
+go get github.com/zalando/go-keyring
+```
+
+Expected: `go.mod` contains `github.com/zalando/go-keyring`.
+
+- [ ] **Step 5: Run generated app baseline tests**
+
+Run:
+
+```powershell
+go test ./...
+npm --prefix frontend run build
+```
+
+Expected: both commands pass with the generated scaffold.
+
+- [ ] **Step 6: Commit scaffold**
+
+Run:
+
+```powershell
+git add -- .
+git commit -m "chore: scaffold wails app"
+```
+
+Expected: scaffold is committed.
+
+## Task 2: Add Domain Models, Config, and Credential Storage
+
+**Files:**
+- Create: `internal/domain/types.go`
+- Create: `internal/apperrors/errors.go`
+- Create: `internal/config/config.go`
+- Create: `internal/config/credentials.go`
+- Create: `internal/config/config_test.go`
+- Create: `internal/config/credentials_test.go`
+
+- [ ] **Step 1: Write failing config tests**
+
+Create `internal/config/config_test.go` with tests covering defaults, JSON persistence, and secret redaction:
+
+```go
+package config
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestLoadMissingConfigReturnsDefaults(t *testing.T) {
+ dir := t.TempDir()
+ cfg, err := Load(filepath.Join(dir, "config.json"))
+ if err != nil {
+ t.Fatalf("Load returned error: %v", err)
+ }
+ if cfg.Gitea.AuthType != "password" {
+ t.Fatalf("default auth type = %q, want password", cfg.Gitea.AuthType)
+ }
+ if !cfg.Update.AutoUpdate || !cfg.Update.CheckOnStartup {
+ t.Fatalf("update defaults should be enabled: %+v", cfg.Update)
+ }
+ if cfg.Update.IntervalMinutes != 60 {
+ t.Fatalf("interval = %d, want 60", cfg.Update.IntervalMinutes)
+ }
+}
+
+func TestSaveDoesNotPersistSecrets(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "config.json")
+ cfg := Default()
+ cfg.Gitea.BaseURL = "https://gitea.example.com"
+ cfg.Gitea.Org = "skills"
+ cfg.Gitea.Username = "alice"
+ cfg.Gitea.CredentialKey = "sgg-ai-skill-manager:gitea"
+ if err := Save(path, cfg); err != nil {
+ t.Fatalf("Save returned error: %v", err)
+ }
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("ReadFile returned error: %v", err)
+ }
+ text := string(raw)
+ if containsAny(text, []string{"password", "token-secret"}) {
+ t.Fatalf("config persisted a secret: %s", text)
+ }
+}
+
+func containsAny(text string, needles []string) bool {
+ for _, needle := range needles {
+ if needle != "" && strings.Contains(text, needle) {
+ return true
+ }
+ }
+ return false
+}
+```
+
+- [ ] **Step 2: Run config tests and verify failure**
+
+Run:
+
+```powershell
+go test ./internal/config -run TestLoadMissingConfigReturnsDefaults -v
+```
+
+Expected: FAIL because `Load` and `Default` do not exist.
+
+- [ ] **Step 3: Implement shared domain types**
+
+Create `internal/domain/types.go`:
+
+```go
+package domain
+
+type Config struct {
+ Gitea GiteaConfig `json:"gitea"`
+ Update UpdateConfig `json:"update"`
+}
+
+type GiteaConfig struct {
+ BaseURL string `json:"baseURL"`
+ Org string `json:"org"`
+ AuthType string `json:"authType"`
+ Username string `json:"username"`
+ CredentialKey string `json:"credentialKey"`
+}
+
+type UpdateConfig struct {
+ AutoUpdate bool `json:"autoUpdate"`
+ CheckOnStartup bool `json:"checkOnStartup"`
+ IntervalMinutes int `json:"intervalMinutes"`
+}
+
+type SaveConfigRequest struct {
+ Config Config `json:"config"`
+ Password string `json:"password"`
+ Token string `json:"token"`
+}
+
+type SkillState struct {
+ Org string `json:"org"`
+ Repo string `json:"repo"`
+ LocalPath string `json:"localPath"`
+ RemoteURL string `json:"remoteURL"`
+ DefaultBranch string `json:"defaultBranch"`
+ CurrentCommit string `json:"currentCommit"`
+ LastCheckedAt string `json:"lastCheckedAt"`
+ LastError string `json:"lastError"`
+ InstalledTargets map[string]InstalledTarget `json:"installedTargets"`
+}
+
+type InstalledTarget struct {
+ Path string `json:"path"`
+ LinkType string `json:"linkType"`
+ TargetPath string `json:"targetPath"`
+}
+
+type State struct {
+ Skills []SkillState `json:"skills"`
+}
+```
+
+- [ ] **Step 4: Implement config package**
+
+Create `internal/config/config.go`:
+
+```go
+package config
+
+import (
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "sgg-ai-skill-manager/internal/domain"
+)
+
+const (
+ AuthPassword = "password"
+ AuthToken = "token"
+)
+
+func Default() domain.Config {
+ return domain.Config{
+ Gitea: domain.GiteaConfig{
+ AuthType: AuthPassword,
+ CredentialKey: "sgg-ai-skill-manager:gitea",
+ },
+ Update: domain.UpdateConfig{
+ AutoUpdate: true,
+ CheckOnStartup: true,
+ IntervalMinutes: 60,
+ },
+ }
+}
+
+func Load(path string) (domain.Config, error) {
+ cfg := Default()
+ raw, err := os.ReadFile(path)
+ if errors.Is(err, os.ErrNotExist) {
+ return cfg, nil
+ }
+ if err != nil {
+ return cfg, err
+ }
+ if len(strings.TrimSpace(string(raw))) == 0 {
+ return cfg, nil
+ }
+ if err := json.Unmarshal(raw, &cfg); err != nil {
+ return cfg, err
+ }
+ applyDefaults(&cfg)
+ return cfg, nil
+}
+
+func Save(path string, cfg domain.Config) error {
+ applyDefaults(&cfg)
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return err
+ }
+ raw, err := json.MarshalIndent(cfg, "", " ")
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(path, append(raw, '\n'), 0o600)
+}
+
+func Validate(cfg domain.Config) error {
+ if strings.TrimSpace(cfg.Gitea.BaseURL) == "" {
+ return errors.New("gitea baseURL is required")
+ }
+ if strings.TrimSpace(cfg.Gitea.Org) == "" {
+ return errors.New("gitea org is required")
+ }
+ if cfg.Gitea.AuthType != AuthPassword && cfg.Gitea.AuthType != AuthToken {
+ return errors.New("authType must be password or token")
+ }
+ if cfg.Update.IntervalMinutes <= 0 {
+ return errors.New("update interval must be positive")
+ }
+ return nil
+}
+
+func applyDefaults(cfg *domain.Config) {
+ if cfg.Gitea.AuthType == "" {
+ cfg.Gitea.AuthType = AuthPassword
+ }
+ if cfg.Gitea.CredentialKey == "" {
+ cfg.Gitea.CredentialKey = "sgg-ai-skill-manager:gitea"
+ }
+ if cfg.Update.IntervalMinutes <= 0 {
+ cfg.Update.IntervalMinutes = 60
+ }
+}
+```
+
+- [ ] **Step 5: Implement credential adapter**
+
+Create `internal/config/credentials.go`:
+
+```go
+package config
+
+import "github.com/zalando/go-keyring"
+
+type SecretStore interface {
+ Set(service, user, secret string) error
+ Get(service, user string) (string, error)
+ Delete(service, user string) error
+}
+
+type KeyringStore struct{}
+
+func (KeyringStore) Set(service, user, secret string) error {
+ return keyring.Set(service, user, secret)
+}
+
+func (KeyringStore) Get(service, user string) (string, error) {
+ return keyring.Get(service, user)
+}
+
+func (KeyringStore) Delete(service, user string) error {
+ return keyring.Delete(service, user)
+}
+```
+
+- [ ] **Step 6: Run tests**
+
+Run:
+
+```powershell
+go test ./internal/config -v
+```
+
+Expected: PASS.
+
+- [ ] **Step 7: Commit config layer**
+
+Run:
+
+```powershell
+git add internal/domain internal/apperrors internal/config go.mod go.sum
+git commit -m "feat: add config and credential storage"
+```
+
+Expected: commit succeeds.
+
+## Task 3: Add Gitea API Client
+
+**Files:**
+- Create: `internal/gitea/client.go`
+- Create: `internal/gitea/client_test.go`
+- Modify: `internal/domain/types.go`
+
+- [ ] **Step 1: Add remote repository types**
+
+Extend `internal/domain/types.go` with:
+
+```go
+type RemoteSkill struct {
+ Name string `json:"name"`
+ FullName string `json:"fullName"`
+ Description string `json:"description"`
+ CloneURL string `json:"cloneURL"`
+ SSHURL string `json:"sshURL"`
+ DefaultBranch string `json:"defaultBranch"`
+ UpdatedAt string `json:"updatedAt"`
+ IsDownloaded bool `json:"isDownloaded"`
+ Status string `json:"status"`
+ Error string `json:"error"`
+}
+
+type TestConnectionResult struct {
+ OK bool `json:"ok"`
+ Message string `json:"message"`
+ Username string `json:"username"`
+ Org string `json:"org"`
+}
+```
+
+- [ ] **Step 2: Write failing Gitea tests**
+
+Create `internal/gitea/client_test.go` with httptest cases:
+
+```go
+package gitea
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestListOrgSkillsFiltersReposWithoutSkillMD(t *testing.T) {
+ mux := http.NewServeMux()
+ mux.HandleFunc("/api/v1/orgs/skills/repos", func(w http.ResponseWriter, r *http.Request) {
+ _ = json.NewEncoder(w).Encode([]apiRepo{
+ {Name: "good", FullName: "skills/good", CloneURL: "https://example/good.git", DefaultBranch: "main"},
+ {Name: "bad", FullName: "skills/bad", CloneURL: "https://example/bad.git", DefaultBranch: "main"},
+ })
+ })
+ mux.HandleFunc("/api/v1/repos/skills/good/contents/SKILL.md", func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ })
+ mux.HandleFunc("/api/v1/repos/skills/bad/contents/SKILL.md", func(w http.ResponseWriter, r *http.Request) {
+ http.NotFound(w, r)
+ })
+ server := httptest.NewServer(mux)
+ defer server.Close()
+
+ client := NewClient(server.URL, Auth{Type: AuthPassword, Username: "alice", Secret: "secret"})
+ repos, err := client.ListOrgSkills(context.Background(), "skills")
+ if err != nil {
+ t.Fatalf("ListOrgSkills returned error: %v", err)
+ }
+ if len(repos) != 1 || repos[0].Name != "good" {
+ t.Fatalf("repos = %+v, want only good", repos)
+ }
+}
+```
+
+- [ ] **Step 3: Run Gitea test and verify failure**
+
+Run:
+
+```powershell
+go test ./internal/gitea -run TestListOrgSkillsFiltersReposWithoutSkillMD -v
+```
+
+Expected: FAIL because package implementation does not exist.
+
+- [ ] **Step 4: Implement Gitea client**
+
+Create `internal/gitea/client.go` with:
+
+```go
+package gitea
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "path"
+ "strings"
+ "time"
+
+ "sgg-ai-skill-manager/internal/domain"
+)
+
+const (
+ AuthPassword = "password"
+ AuthToken = "token"
+)
+
+type Auth struct {
+ Type string
+ Username string
+ Secret string
+}
+
+type Client struct {
+ baseURL string
+ auth Auth
+ httpClient *http.Client
+}
+
+type apiRepo struct {
+ Name string `json:"name"`
+ FullName string `json:"full_name"`
+ Description string `json:"description"`
+ CloneURL string `json:"clone_url"`
+ SSHURL string `json:"ssh_url"`
+ DefaultBranch string `json:"default_branch"`
+ UpdatedAt string `json:"updated_at"`
+}
+
+func NewClient(baseURL string, auth Auth) *Client {
+ return &Client{
+ baseURL: strings.TrimRight(baseURL, "/"),
+ auth: auth,
+ httpClient: &http.Client{Timeout: 20 * time.Second},
+ }
+}
+
+func (c *Client) ListOrgSkills(ctx context.Context, org string) ([]domain.RemoteSkill, error) {
+ var out []domain.RemoteSkill
+ for page := 1; ; page++ {
+ var repos []apiRepo
+ endpoint := fmt.Sprintf("/api/v1/orgs/%s/repos?page=%d&limit=50", url.PathEscape(org), page)
+ if err := c.getJSON(ctx, endpoint, &repos); err != nil {
+ return nil, err
+ }
+ if len(repos) == 0 {
+ break
+ }
+ for _, repo := range repos {
+ ok, err := c.HasRootSkill(ctx, org, repo.Name, repo.DefaultBranch)
+ if err != nil {
+ out = append(out, domain.RemoteSkill{Name: repo.Name, FullName: repo.FullName, Status: "check_failed", Error: err.Error()})
+ continue
+ }
+ if !ok {
+ continue
+ }
+ out = append(out, domain.RemoteSkill{
+ Name: repo.Name,
+ FullName: repo.FullName,
+ Description: repo.Description,
+ CloneURL: repo.CloneURL,
+ SSHURL: repo.SSHURL,
+ DefaultBranch: repo.DefaultBranch,
+ UpdatedAt: repo.UpdatedAt,
+ Status: "remote",
+ })
+ }
+ if len(repos) < 50 {
+ break
+ }
+ }
+ return out, nil
+}
+
+func (c *Client) HasRootSkill(ctx context.Context, org, repo, ref string) (bool, error) {
+ escaped := path.Join(url.PathEscape(org), url.PathEscape(repo))
+ endpoint := "/api/v1/repos/" + escaped + "/contents/SKILL.md"
+ if ref != "" {
+ endpoint += "?ref=" + url.QueryEscape(ref)
+ }
+ req, err := c.newRequest(ctx, http.MethodGet, endpoint)
+ if err != nil {
+ return false, err
+ }
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return false, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode == http.StatusNotFound {
+ return false, nil
+ }
+ if resp.StatusCode >= 200 && resp.StatusCode < 300 {
+ return true, nil
+ }
+ return false, fmt.Errorf("check SKILL.md failed: %s", resp.Status)
+}
+
+func (c *Client) getJSON(ctx context.Context, endpoint string, target any) error {
+ req, err := c.newRequest(ctx, http.MethodGet, endpoint)
+ if err != nil {
+ return err
+ }
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return fmt.Errorf("gitea request failed: %s", resp.Status)
+ }
+ return json.NewDecoder(resp.Body).Decode(target)
+}
+
+func (c *Client) newRequest(ctx context.Context, method, endpoint string) (*http.Request, error) {
+ req, err := http.NewRequestWithContext(ctx, method, c.baseURL+endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+ if c.auth.Type == AuthToken {
+ req.Header.Set("Authorization", "token "+c.auth.Secret)
+ } else if c.auth.Username != "" || c.auth.Secret != "" {
+ req.SetBasicAuth(c.auth.Username, c.auth.Secret)
+ }
+ return req, nil
+}
+```
+
+- [ ] **Step 5: Run Gitea tests**
+
+Run:
+
+```powershell
+go test ./internal/gitea -v
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Commit Gitea client**
+
+Run:
+
+```powershell
+git add internal/domain internal/gitea
+git commit -m "feat: add gitea skill discovery"
+```
+
+Expected: commit succeeds.
+
+## Task 4: Add Git Operations
+
+**Files:**
+- Create: `internal/gitops/git.go`
+- Create: `internal/gitops/git_test.go`
+- Modify: `internal/domain/types.go`
+
+- [ ] **Step 1: Write failing git tests**
+
+Create `internal/gitops/git_test.go`:
+
+```go
+package gitops
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestLocalCommitAndDirtyStatus(t *testing.T) {
+ dir := t.TempDir()
+ runner := New()
+ runGit(t, dir, "init")
+ runGit(t, dir, "config", "user.email", "test@example.com")
+ runGit(t, dir, "config", "user.name", "Test")
+ if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("# Skill\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ runGit(t, dir, "add", "SKILL.md")
+ runGit(t, dir, "commit", "-m", "initial")
+ commit, err := runner.CurrentCommit(dir)
+ if err != nil {
+ t.Fatalf("CurrentCommit returned error: %v", err)
+ }
+ if len(commit) != 40 {
+ t.Fatalf("commit length = %d, want 40", len(commit))
+ }
+ dirty, err := runner.HasLocalChanges(dir)
+ if err != nil {
+ t.Fatalf("HasLocalChanges returned error: %v", err)
+ }
+ if dirty {
+ t.Fatal("repo should be clean")
+ }
+ if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("# Changed\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ dirty, err = runner.HasLocalChanges(dir)
+ if err != nil {
+ t.Fatalf("HasLocalChanges returned error: %v", err)
+ }
+ if !dirty {
+ t.Fatal("repo should be dirty")
+ }
+}
+```
+
+Implement `runGit` helper in the test with `exec.Command("git", args...)`.
+
+- [ ] **Step 2: Run git tests and verify failure**
+
+Run:
+
+```powershell
+go test ./internal/gitops -run TestLocalCommitAndDirtyStatus -v
+```
+
+Expected: FAIL because implementation does not exist.
+
+- [ ] **Step 3: Implement git runner**
+
+Create `internal/gitops/git.go`:
+
+```go
+package gitops
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+)
+
+type Credentials struct {
+ Username string
+ Secret string
+}
+
+type Runner struct{}
+
+func New() *Runner {
+ return &Runner{}
+}
+
+func (r *Runner) CheckAvailable() error {
+ _, err := exec.LookPath("git")
+ return err
+}
+
+func (r *Runner) Clone(ctx context.Context, remoteURL, dest string, creds Credentials) error {
+ if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
+ return err
+ }
+ return r.run(ctx, "", creds, "clone", remoteURL, dest)
+}
+
+func (r *Runner) Pull(ctx context.Context, repoPath string, creds Credentials) error {
+ return r.run(ctx, repoPath, creds, "pull", "--ff-only")
+}
+
+func (r *Runner) Fetch(ctx context.Context, repoPath string, creds Credentials) error {
+ return r.run(ctx, repoPath, creds, "fetch", "origin")
+}
+
+func (r *Runner) CurrentCommit(repoPath string) (string, error) {
+ return r.output(context.Background(), repoPath, Credentials{}, "rev-parse", "HEAD")
+}
+
+func (r *Runner) RemoteCommit(ctx context.Context, repoPath, branch string, creds Credentials) (string, error) {
+ ref := "refs/heads/" + branch
+ out, err := r.output(ctx, repoPath, creds, "ls-remote", "origin", ref)
+ if err != nil {
+ return "", err
+ }
+ fields := strings.Fields(out)
+ if len(fields) == 0 {
+ return "", fmt.Errorf("remote branch %s not found", branch)
+ }
+ return fields[0], nil
+}
+
+func (r *Runner) HasLocalChanges(repoPath string) (bool, error) {
+ out, err := r.output(context.Background(), repoPath, Credentials{}, "status", "--porcelain")
+ if err != nil {
+ return false, err
+ }
+ return strings.TrimSpace(out) != "", nil
+}
+
+func (r *Runner) run(ctx context.Context, dir string, creds Credentials, args ...string) error {
+ _, err := r.command(ctx, dir, creds, args...).CombinedOutput()
+ return err
+}
+
+func (r *Runner) output(ctx context.Context, dir string, creds Credentials, args ...string) (string, error) {
+ out, err := r.command(ctx, dir, creds, args...).CombinedOutput()
+ if err != nil {
+ return "", fmt.Errorf("git %s failed: %w: %s", strings.Join(args, " "), err, string(out))
+ }
+ return strings.TrimSpace(string(out)), nil
+}
+
+func (r *Runner) command(ctx context.Context, dir string, creds Credentials, args ...string) *exec.Cmd {
+ fullArgs := append([]string{"-c", "credential.helper=", "-c", "credential.useHttpPath=true"}, args...)
+ cmd := exec.CommandContext(ctx, "git", fullArgs...)
+ cmd.Dir = dir
+ cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
+ if creds.Username != "" || creds.Secret != "" {
+ cmd.Env = append(cmd.Env, "GIT_ASKPASS="+askPassScript(), "GITEA_USERNAME="+creds.Username, "GITEA_PASSWORD="+creds.Secret)
+ }
+ return cmd
+}
+
+func askPassScript() string {
+ return filepath.Join(os.TempDir(), "sgg-ai-skill-manager-git-askpass.cmd")
+}
+```
+
+Add creation of the askpass script before authenticated commands:
+
+```go
+func ensureAskPassScript(path string) error {
+ content := "@echo off\r\nset prompt=%~1\r\necho %prompt% | findstr /I \"username\" >nul\r\nif %ERRORLEVEL%==0 (echo %GITEA_USERNAME%) else (echo %GITEA_PASSWORD%)\r\n"
+ return os.WriteFile(path, []byte(content), 0o600)
+}
+```
+
+Call `ensureAskPassScript` inside `command` when credentials are present.
+
+- [ ] **Step 4: Run git tests**
+
+Run:
+
+```powershell
+go test ./internal/gitops -v
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Commit git operations**
+
+Run:
+
+```powershell
+git add internal/gitops
+git commit -m "feat: add git operations"
+```
+
+Expected: commit succeeds.
+
+## Task 5: Add State Store and Target Junction Management
+
+**Files:**
+- Create: `internal/skillstore/store.go`
+- Create: `internal/skillstore/store_test.go`
+- Create: `internal/targets/targets.go`
+- Create: `internal/targets/junction_windows.go`
+- Create: `internal/targets/targets_test.go`
+- Modify: `internal/domain/types.go`
+
+- [ ] **Step 1: Write failing skillstore tests**
+
+Create tests for state save/load and local path generation:
+
+```go
+package skillstore
+
+import (
+ "path/filepath"
+ "testing"
+
+ "sgg-ai-skill-manager/internal/domain"
+)
+
+func TestStoreRoundTrip(t *testing.T) {
+ dir := t.TempDir()
+ store := New(filepath.Join(dir, "state.json"), filepath.Join(dir, "repos"))
+ state := domain.State{Skills: []domain.SkillState{{Org: "skills", Repo: "demo", LocalPath: store.LocalRepoPath("skills", "demo")}}}
+ if err := store.Save(state); err != nil {
+ t.Fatalf("Save returned error: %v", err)
+ }
+ got, err := store.Load()
+ if err != nil {
+ t.Fatalf("Load returned error: %v", err)
+ }
+ if len(got.Skills) != 1 || got.Skills[0].Repo != "demo" {
+ t.Fatalf("state = %+v", got)
+ }
+}
+```
+
+- [ ] **Step 2: Implement skillstore**
+
+Create `internal/skillstore/store.go` with load/save, path sanitisation that rejects empty names and path separators, `UpsertSkill`, `RemoveSkill`, and `FindSkill`.
+
+- [ ] **Step 3: Write failing target tests**
+
+Create tests using a temp directory target root. The test should create a source skill directory, install it, confirm the target path exists as a reparse point/Junction, uninstall it, and confirm the source remains.
+
+- [ ] **Step 4: Implement targets and Junction operations**
+
+Implement:
+
+```go
+type Target struct {
+ ID string
+ Name string
+ SkillsDir string
+}
+
+type Manager struct {
+ Targets map[string]Target
+}
+
+func DefaultTargets(home string) map[string]Target {
+ return map[string]Target{
+ "codex": {ID: "codex", Name: "Codex", SkillsDir: filepath.Join(home, ".codex", "skills")},
+ "claude": {ID: "claude", Name: "Claude", SkillsDir: filepath.Join(home, ".claude", "skills")},
+ }
+}
+```
+
+Use `cmd.exe /c mklink /J ` to create Junctions. Use `os.Lstat` and `os.Readlink` where supported to verify the link target before uninstall. If `os.Readlink` fails on a Junction, use `cmd.exe /c dir /AL ` parsing only as a fallback.
+
+- [ ] **Step 5: Run store and target tests**
+
+Run:
+
+```powershell
+go test ./internal/skillstore ./internal/targets -v
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Commit store and targets**
+
+Run:
+
+```powershell
+git add internal/skillstore internal/targets internal/domain
+git commit -m "feat: add local skill store and targets"
+```
+
+Expected: commit succeeds.
+
+## Task 6: Add Application Service and Updater
+
+**Files:**
+- Create: `internal/service/service.go`
+- Create: `internal/service/updater.go`
+- Create: `internal/service/service_test.go`
+- Modify: `app.go`
+
+- [ ] **Step 1: Write failing service tests**
+
+Create service tests with fakes for Gitea, git, credential store, and targets. Cover:
+
+- save config stores secret via secret store and JSON without secret
+- list remote skills merges downloaded status
+- install updates state only after target install succeeds
+- delete local skill calls uninstall first
+- auto update skips dirty repositories
+
+- [ ] **Step 2: Implement service interfaces**
+
+Define small interfaces inside `internal/service/service.go`:
+
+```go
+type GiteaClient interface {
+ ListOrgSkills(ctx context.Context, org string) ([]domain.RemoteSkill, error)
+}
+
+type GitRunner interface {
+ Clone(ctx context.Context, remoteURL, dest string, creds gitops.Credentials) error
+ Pull(ctx context.Context, repoPath string, creds gitops.Credentials) error
+ CurrentCommit(repoPath string) (string, error)
+ RemoteCommit(ctx context.Context, repoPath, branch string, creds gitops.Credentials) (string, error)
+ HasLocalChanges(repoPath string) (bool, error)
+}
+```
+
+- [ ] **Step 3: Implement service methods**
+
+Implement methods used by Wails:
+
+```go
+LoadConfig() (domain.Config, error)
+SaveConfig(ctx context.Context, req domain.SaveConfigRequest) error
+TestConnection(ctx context.Context, req domain.SaveConfigRequest) (domain.TestConnectionResult, error)
+ListRemoteSkills(ctx context.Context) ([]domain.RemoteSkill, error)
+ListLocalSkills(ctx context.Context) ([]domain.SkillState, error)
+DownloadSkill(ctx context.Context, repo domain.RemoteSkill) (domain.SkillState, error)
+UpdateSkill(ctx context.Context, org, repo string) (domain.SkillState, error)
+InstallSkill(ctx context.Context, org, repo, targetID string) (domain.SkillState, error)
+UninstallSkill(ctx context.Context, org, repo, targetID string) (domain.SkillState, error)
+DeleteSkill(ctx context.Context, org, repo string) error
+OpenInVSCode(ctx context.Context, org, repo string) error
+OpenFolder(ctx context.Context, org, repo string) error
+RunAutoUpdate(ctx context.Context) error
+```
+
+- [ ] **Step 4: Wire Wails facade**
+
+Modify `app.go` so the Wails `App` struct delegates to `service.Service`. Keep Wails methods thin and JSON-friendly.
+
+- [ ] **Step 5: Run service tests**
+
+Run:
+
+```powershell
+go test ./internal/service -v
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Commit service layer**
+
+Run:
+
+```powershell
+git add app.go internal/service
+git commit -m "feat: add skill management service"
+```
+
+Expected: commit succeeds.
+
+## Task 7: Build React Management UI
+
+**Files:**
+- Modify: `frontend/src/App.tsx`
+- Modify: `frontend/src/App.css`
+- Create: `frontend/src/api.ts`
+- Create: `frontend/src/types.ts`
+
+- [ ] **Step 1: Define frontend types and API wrapper**
+
+Create `frontend/src/types.ts` mirroring the backend JSON shapes. Create `frontend/src/api.ts` wrapping Wails-generated functions from `../wailsjs/go/main/App`.
+
+- [ ] **Step 2: Replace generated UI with three-tab app**
+
+Implement tabs:
+
+- Config
+- Remote Market
+- Local Skills
+
+Use compact management layout, not a landing page. Use table rows for remote and local skill lists.
+
+- [ ] **Step 3: Implement Config tab**
+
+Fields:
+
+- Gitea baseURL
+- auth type segmented control: password/token
+- username
+- password
+- token
+- org
+- auto update checkbox
+- check on startup checkbox
+- interval minutes number input
+
+Buttons:
+
+- Save
+- Test Connection
+
+- [ ] **Step 4: Implement Remote Market tab**
+
+Controls:
+
+- search input
+- refresh button
+
+Rows:
+
+- name
+- description
+- default branch
+- updated at
+- status
+- actions: download/update/open local
+
+- [ ] **Step 5: Implement Local Skills tab**
+
+Rows:
+
+- name
+- path
+- commit
+- update status
+- Codex status
+- Claude status
+- actions: update, install/uninstall Codex, install/uninstall Claude, VS Code, folder, delete
+
+- [ ] **Step 6: Add CSS**
+
+Style for a quiet desktop management tool:
+
+- left or top navigation tabs
+- dense tables
+- restrained colors
+- fixed button sizes where possible
+- no hero page
+- no decorative gradients
+- no nested cards
+
+- [ ] **Step 7: Build frontend**
+
+Run:
+
+```powershell
+npm --prefix frontend run build
+```
+
+Expected: PASS.
+
+- [ ] **Step 8: Commit frontend**
+
+Run:
+
+```powershell
+git add frontend
+git commit -m "feat: add skill manager interface"
+```
+
+Expected: commit succeeds.
+
+## Task 8: Final Integration and Verification
+
+**Files:**
+- Modify: `README.md`
+- Modify: generated Wails bindings under `frontend/wailsjs` if Wails regenerates them
+
+- [ ] **Step 1: Generate Wails bindings**
+
+Run:
+
+```powershell
+wails generate module
+```
+
+If the command is not supported in this Wails version, run:
+
+```powershell
+wails build
+```
+
+Expected: frontend Wails bindings include the App methods.
+
+- [ ] **Step 2: Run all backend tests**
+
+Run:
+
+```powershell
+go test ./...
+```
+
+Expected: PASS.
+
+- [ ] **Step 3: Run frontend build**
+
+Run:
+
+```powershell
+npm --prefix frontend run build
+```
+
+Expected: PASS.
+
+- [ ] **Step 4: Build Wails app**
+
+Run:
+
+```powershell
+wails build
+```
+
+Expected: Windows build succeeds.
+
+- [ ] **Step 5: Add README usage notes**
+
+Document:
+
+- configure Gitea baseURL, username/password or token, and org
+- remote market only shows repos with root `SKILL.md`
+- local repos are stored under `%APPDATA%\sgg-ai-skill-manager`
+- install creates Junctions under `.codex\skills` and `.claude\skills`
+- uninstall removes only Junctions
+- delete removes local cloned skill after safe uninstall
+
+- [ ] **Step 6: Commit final integration**
+
+Run:
+
+```powershell
+git add -- .
+git commit -m "docs: add usage notes"
+```
+
+Expected: commit succeeds.
+
+## Self-Review
+
+Spec coverage:
+
+- Config page: Task 2, Task 6, Task 7.
+- Gitea org repository discovery: Task 3, Task 6, Task 7.
+- Root `SKILL.md` filtering: Task 3.
+- Clone and update: Task 4, Task 6.
+- APPDATA local storage: Task 2, Task 5.
+- Codex and Claude install using Junctions: Task 5, Task 6, Task 7.
+- Safe uninstall and delete rules: Task 5, Task 6.
+- VS Code and folder open: Task 6, Task 7.
+- Automatic startup and interval update: Task 6.
+- Verification: Task 8.
+
+Placeholder scan:
+
+- The plan has no placeholder markers or vague future tasks.
+
+Type consistency:
+
+- Backend JSON uses `baseURL`, `authType`, `credentialKey`, `installedTargets`, and target IDs `codex` and `claude` consistently across backend and frontend tasks.