package service import ( "context" "errors" "fmt" "os" "os/exec" "path/filepath" "strings" "time" "sgg-ai-skill-manager/internal/config" "sgg-ai-skill-manager/internal/domain" "sgg-ai-skill-manager/internal/gitea" "sgg-ai-skill-manager/internal/gitops" "sgg-ai-skill-manager/internal/skillstore" "sgg-ai-skill-manager/internal/targets" ) type Paths struct { AppDir string ConfigPath string StatePath string ReposRoot string HomeDir string } type RemoteClient interface { ListOrgSkills(ctx context.Context, org string) ([]domain.RemoteSkill, error) TestConnection(ctx context.Context, org string) (domain.TestConnectionResult, error) } type RemoteMaker func(ctx context.Context, cfg domain.Config, secret string) (RemoteClient, 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) } type TargetManager interface { Install(skill domain.SkillState, targetID string) (domain.SkillState, error) Uninstall(skill domain.SkillState, targetID string) (domain.SkillState, error) Status(skill domain.SkillState, targetID string) string } type Opener interface { OpenVSCode(path string) error OpenFolder(path string) error } type Options struct { Paths Paths Secrets config.SecretStore Git GitRunner Targets TargetManager RemoteMaker RemoteMaker Opener Opener } type Service struct { paths Paths secrets config.SecretStore git GitRunner targets TargetManager remoteMaker RemoteMaker opener Opener store *skillstore.Store } func New(opts Options) *Service { paths := opts.Paths if paths.AppDir == "" { paths, _ = DefaultPaths() } secrets := opts.Secrets if secrets == nil { secrets = config.KeyringStore{} } gitRunner := opts.Git if gitRunner == nil { gitRunner = gitops.New() } targetManager := opts.Targets if targetManager == nil { targetManager = targets.New(targets.DefaultTargets(paths.HomeDir)) } remoteMaker := opts.RemoteMaker if remoteMaker == nil { remoteMaker = defaultRemoteMaker } opener := opts.Opener if opener == nil { opener = systemOpener{} } return &Service{ paths: paths, secrets: secrets, git: gitRunner, targets: targetManager, remoteMaker: remoteMaker, opener: opener, store: skillstore.New(paths.StatePath, paths.ReposRoot), } } func NewDefault() (*Service, error) { paths, err := DefaultPaths() if err != nil { return nil, err } return New(Options{Paths: paths}), nil } func DefaultPaths() (Paths, error) { configDir, err := os.UserConfigDir() if err != nil { return Paths{}, err } home, err := os.UserHomeDir() if err != nil { return Paths{}, err } appDir := filepath.Join(configDir, "sgg-ai-skill-manager") return Paths{ AppDir: appDir, ConfigPath: filepath.Join(appDir, "config.json"), StatePath: filepath.Join(appDir, "state.json"), ReposRoot: filepath.Join(appDir, "repos"), HomeDir: home, }, nil } func (s *Service) LoadConfig() (domain.Config, error) { return config.Load(s.paths.ConfigPath) } func (s *Service) SaveConfig(ctx context.Context, req domain.SaveConfigRequest) error { _ = ctx cfg := req.Config if err := config.Validate(cfg); err != nil { return err } secret := requestSecret(req) if secret != "" { if err := s.secrets.Set(cfg.Gitea.CredentialKey, credentialUser(cfg), secret); err != nil { return err } } return config.Save(s.paths.ConfigPath, cfg) } func (s *Service) TestConnection(ctx context.Context, req domain.SaveConfigRequest) (domain.TestConnectionResult, error) { cfg := req.Config if err := config.Validate(cfg); err != nil { return domain.TestConnectionResult{}, err } client, err := s.remoteMaker(ctx, cfg, requestSecret(req)) if err != nil { return domain.TestConnectionResult{}, err } return client.TestConnection(ctx, cfg.Gitea.Org) } func (s *Service) ListRemoteSkills(ctx context.Context) ([]domain.RemoteSkill, error) { cfg, client, err := s.savedRemoteClient(ctx) if err != nil { return nil, err } remoteSkills, err := client.ListOrgSkills(ctx, cfg.Gitea.Org) if err != nil { return nil, err } state, err := s.store.Load() if err != nil { return nil, err } for i := range remoteSkills { if _, ok := skillstore.FindSkill(state, cfg.Gitea.Org, remoteSkills[i].Name); ok { remoteSkills[i].IsDownloaded = true remoteSkills[i].Status = "downloaded" } } return remoteSkills, nil } func (s *Service) ListLocalSkills(ctx context.Context) ([]domain.SkillState, error) { _ = ctx state, err := s.store.Load() if err != nil { return nil, err } return state.Skills, nil } func (s *Service) DownloadSkill(ctx context.Context, repo domain.RemoteSkill) (domain.SkillState, error) { cfg, secret, err := s.savedConfigAndSecret() if err != nil { return domain.SkillState{}, err } localPath, err := s.store.SafeLocalRepoPath(cfg.Gitea.Org, repo.Name) if err != nil { return domain.SkillState{}, err } if _, err := os.Stat(localPath); errors.Is(err, os.ErrNotExist) { if err := s.git.Clone(ctx, repo.CloneURL, localPath, gitops.Credentials{Username: cfg.Gitea.Username, Secret: secret}); err != nil { return domain.SkillState{}, err } } else if err != nil { return domain.SkillState{}, err } commit, err := s.git.CurrentCommit(localPath) if err != nil { return domain.SkillState{}, err } skill := domain.SkillState{ Org: cfg.Gitea.Org, Repo: repo.Name, LocalPath: localPath, RemoteURL: repo.CloneURL, DefaultBranch: defaultBranch(repo.DefaultBranch), CurrentCommit: commit, LastCheckedAt: time.Now().Format(time.RFC3339), InstalledTargets: map[string]domain.InstalledTarget{}, } return s.saveSkill(skill) } func (s *Service) UpdateSkill(ctx context.Context, org, repo string) (domain.SkillState, error) { cfg, secret, err := s.savedConfigAndSecret() if err != nil { return domain.SkillState{}, err } state, skill, err := s.findSkill(org, repo) if err != nil { return domain.SkillState{}, err } dirty, err := s.git.HasLocalChanges(skill.LocalPath) if err != nil { return domain.SkillState{}, err } if dirty { skill.LastError = "local changes present" state = skillstore.UpsertSkill(state, skill) return skill, s.store.Save(state) } if err := s.git.Pull(ctx, skill.LocalPath, gitops.Credentials{Username: cfg.Gitea.Username, Secret: secret}); err != nil { return domain.SkillState{}, err } commit, err := s.git.CurrentCommit(skill.LocalPath) if err != nil { return domain.SkillState{}, err } skill.CurrentCommit = commit skill.LastCheckedAt = time.Now().Format(time.RFC3339) skill.LastError = "" state = skillstore.UpsertSkill(state, skill) return skill, s.store.Save(state) } func (s *Service) InstallSkill(ctx context.Context, org, repo, targetID string) (domain.SkillState, error) { _ = ctx state, skill, err := s.findSkill(org, repo) if err != nil { return domain.SkillState{}, err } installed, err := s.targets.Install(skill, targetID) if err != nil { return domain.SkillState{}, err } state = skillstore.UpsertSkill(state, installed) return installed, s.store.Save(state) } func (s *Service) UninstallSkill(ctx context.Context, org, repo, targetID string) (domain.SkillState, error) { _ = ctx state, skill, err := s.findSkill(org, repo) if err != nil { return domain.SkillState{}, err } uninstalled, err := s.targets.Uninstall(skill, targetID) if err != nil { return domain.SkillState{}, err } state = skillstore.UpsertSkill(state, uninstalled) return uninstalled, s.store.Save(state) } func (s *Service) DeleteSkill(ctx context.Context, org, repo string) error { _ = ctx state, skill, err := s.findSkill(org, repo) if err != nil { return err } for targetID := range skill.InstalledTargets { var uninstallErr error skill, uninstallErr = s.targets.Uninstall(skill, targetID) if uninstallErr != nil { return uninstallErr } } if err := s.ensureManagedPath(skill.LocalPath); err != nil { return err } if err := os.RemoveAll(skill.LocalPath); err != nil { return err } state = skillstore.RemoveSkill(state, org, repo) return s.store.Save(state) } func (s *Service) OpenInVSCode(ctx context.Context, org, repo string) error { _ = ctx _, skill, err := s.findSkill(org, repo) if err != nil { return err } return s.opener.OpenVSCode(skill.LocalPath) } func (s *Service) OpenFolder(ctx context.Context, org, repo string) error { _ = ctx _, skill, err := s.findSkill(org, repo) if err != nil { return err } return s.opener.OpenFolder(skill.LocalPath) } func (s *Service) RunAutoUpdate(ctx context.Context) error { cfg, secret, err := s.savedConfigAndSecret() if err != nil { return err } if !cfg.Update.AutoUpdate { return nil } state, err := s.store.Load() if err != nil { return err } creds := gitops.Credentials{Username: cfg.Gitea.Username, Secret: secret} for i := range state.Skills { skill := state.Skills[i] dirty, err := s.git.HasLocalChanges(skill.LocalPath) if err != nil { skill.LastError = err.Error() state.Skills[i] = skill continue } if dirty { skill.LastError = "local changes present" skill.LastCheckedAt = time.Now().Format(time.RFC3339) state.Skills[i] = skill continue } branch := defaultBranch(skill.DefaultBranch) remoteCommit, err := s.git.RemoteCommit(ctx, skill.LocalPath, branch, creds) if err != nil { skill.LastError = err.Error() state.Skills[i] = skill continue } if remoteCommit != "" && skill.CurrentCommit == remoteCommit { skill.LastError = "" skill.LastCheckedAt = time.Now().Format(time.RFC3339) state.Skills[i] = skill continue } if err := s.git.Pull(ctx, skill.LocalPath, creds); err != nil { skill.LastError = err.Error() state.Skills[i] = skill continue } if commit, err := s.git.CurrentCommit(skill.LocalPath); err == nil { skill.CurrentCommit = commit } else { skill.LastError = err.Error() } skill.LastError = "" skill.LastCheckedAt = time.Now().Format(time.RFC3339) state.Skills[i] = skill } return s.store.Save(state) } func (s *Service) savedRemoteClient(ctx context.Context) (domain.Config, RemoteClient, error) { cfg, secret, err := s.savedConfigAndSecret() if err != nil { return domain.Config{}, nil, err } client, err := s.remoteMaker(ctx, cfg, secret) return cfg, client, err } func (s *Service) savedConfigAndSecret() (domain.Config, string, error) { cfg, err := config.Load(s.paths.ConfigPath) if err != nil { return domain.Config{}, "", err } if err := config.Validate(cfg); err != nil { return domain.Config{}, "", err } secret, err := s.secrets.Get(cfg.Gitea.CredentialKey, credentialUser(cfg)) if err != nil { return domain.Config{}, "", err } return cfg, secret, nil } func (s *Service) saveSkill(skill domain.SkillState) (domain.SkillState, error) { state, err := s.store.Load() if err != nil { return domain.SkillState{}, err } state = skillstore.UpsertSkill(state, skill) return skill, s.store.Save(state) } func (s *Service) findSkill(org, repo string) (domain.State, domain.SkillState, error) { state, err := s.store.Load() if err != nil { return domain.State{}, domain.SkillState{}, err } skill, ok := skillstore.FindSkill(state, org, repo) if !ok { return state, domain.SkillState{}, fmt.Errorf("skill %s/%s not found", org, repo) } if skill.InstalledTargets == nil { skill.InstalledTargets = map[string]domain.InstalledTarget{} } return state, skill, nil } func (s *Service) ensureManagedPath(path string) error { absRoot, err := filepath.Abs(s.paths.ReposRoot) if err != nil { return err } absPath, err := filepath.Abs(path) if err != nil { return err } rel, err := filepath.Rel(absRoot, absPath) if err != nil { return err } if rel == "." || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) { return fmt.Errorf("refusing to delete unmanaged path: %s", path) } return nil } func defaultRemoteMaker(_ context.Context, cfg domain.Config, secret string) (RemoteClient, error) { return gitea.NewClient(cfg.Gitea.BaseURL, gitea.Auth{ Type: cfg.Gitea.AuthType, Username: cfg.Gitea.Username, Secret: secret, }), nil } func requestSecret(req domain.SaveConfigRequest) string { if req.Config.Gitea.AuthType == config.AuthToken { return req.Token } return req.Password } func credentialUser(cfg domain.Config) string { if cfg.Gitea.AuthType == config.AuthToken { return "token" } return cfg.Gitea.Username } func defaultBranch(branch string) string { if strings.TrimSpace(branch) == "" { return "main" } return branch } type systemOpener struct{} func (systemOpener) OpenVSCode(path string) error { cmd := exec.Command("code", ".", "-n") cmd.Dir = path return cmd.Start() } func (systemOpener) OpenFolder(path string) error { return exec.Command("explorer.exe", path).Start() }