# 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.