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.