1245 lines
31 KiB
Markdown
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.
|