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

1245 lines
31 KiB
Markdown

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