Files
sgg-sgg-ai-skill-manage-win…/docs/superpowers/plans/2026-05-13-ai-skill-manager.md

31 KiB

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:

git status --short

Expected: no uncommitted files except this plan if it has not been committed yet.

  • Step 2: Create Wails scaffold

Run:

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:

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:

npm --prefix frontend install lucide-react

Expected: frontend/package.json contains lucide-react.

  • Step 4: Add backend credential dependency

Run:

go get github.com/zalando/go-keyring

Expected: go.mod contains github.com/zalando/go-keyring.

  • Step 5: Run generated app baseline tests

Run:

go test ./...
npm --prefix frontend run build

Expected: both commands pass with the generated scaffold.

  • Step 6: Commit scaffold

Run:

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:

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:

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:

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:

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:

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:

go test ./internal/config -v

Expected: PASS.

  • Step 7: Commit config layer

Run:

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:

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:

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:

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:

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:

go test ./internal/gitea -v

Expected: PASS.

  • Step 6: Commit Gitea client

Run:

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:

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:

go test ./internal/gitops -run TestLocalCommitAndDirtyStatus -v

Expected: FAIL because implementation does not exist.

  • Step 3: Implement git runner

Create internal/gitops/git.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:

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:

go test ./internal/gitops -v

Expected: PASS.

  • Step 5: Commit git operations

Run:

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:

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:

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 <link> <target> 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 <parent> parsing only as a fallback.

  • Step 5: Run store and target tests

Run:

go test ./internal/skillstore ./internal/targets -v

Expected: PASS.

  • Step 6: Commit store and targets

Run:

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:

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:

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:

go test ./internal/service -v

Expected: PASS.

  • Step 6: Commit service layer

Run:

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:

npm --prefix frontend run build

Expected: PASS.

  • Step 8: Commit frontend

Run:

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:

wails generate module

If the command is not supported in this Wails version, run:

wails build

Expected: frontend Wails bindings include the App methods.

  • Step 2: Run all backend tests

Run:

go test ./...

Expected: PASS.

  • Step 3: Run frontend build

Run:

npm --prefix frontend run build

Expected: PASS.

  • Step 4: Build Wails app

Run:

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:

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.