From e18bf17f57b6c11d8df64e3cd6dac10e4df62f9d Mon Sep 17 00:00:00 2001 From: middleDuckAi <269711613+middleDuckAi@users.noreply.github.com> Date: Thu, 4 Jun 2026 19:34:03 +0700 Subject: [PATCH 1/3] [ADD] Support CLI EVO skills install lane --- cmd/evo/main.go | 69 ++- cmd/evo/main_test.go | 74 +++ internal/engine/install/engine.go | 7 + internal/engine/install/skills.go | 631 +++++++++++++++++++++++++ internal/engine/install/skills_test.go | 208 ++++++++ 5 files changed, 988 insertions(+), 1 deletion(-) create mode 100644 internal/engine/install/skills.go create mode 100644 internal/engine/install/skills_test.go diff --git a/cmd/evo/main.go b/cmd/evo/main.go index 17ad974..abbeac9 100644 --- a/cmd/evo/main.go +++ b/cmd/evo/main.go @@ -221,6 +221,11 @@ func runInstall(ctx context.Context, args []string) int { githubPat := fs.String("github-pat", "", "GitHub PAT token for API requests") githubPatAlt := fs.String("github_pat", "", "GitHub PAT token for API requests") extras := fs.String("extras", "", "Comma-separated extras to install (e.g., sTask@main,sSeo)") + skills := fs.String("skills", "", "Comma-separated EVO skills to install in CLI mode (default, none, or skill names)") + skillsSource := fs.String("skills-source", "", "Local path to the evo-skills source checkout") + skillsRef := fs.String("skills-ref", "", "Git ref/hash to record for EVO skills source") + skillsLink := fs.Bool("skills-link", false, "Symlink EVO skills from a local source instead of copying") + skillsDryRun := fs.Bool("skills-dry-run", false, "Plan EVO skills install without writing target files") logToFile := fs.Bool("log", false, "Write installer log to file") cliMode := fs.Bool("cli", false, "Run in non-interactive CLI mode (no TUI)") quiet := fs.Bool("quiet", false, "Reduce CLI output (warnings/errors only)") @@ -233,6 +238,10 @@ func runInstall(ctx context.Context, args []string) int { if strings.TrimSpace(installDir) == "" && *cliMode { installDir = "." } + if err := validateSkillsCLIOptions(*skills, *cliMode, *skillsLink, *skillsSource, *skillsRef); err != nil { + fmt.Fprintln(os.Stderr, err) + return 2 + } pat := strings.TrimSpace(*githubPat) if pat == "" { pat = strings.TrimSpace(*githubPatAlt) @@ -259,6 +268,16 @@ func runInstall(ctx context.Context, args []string) int { Language: strings.ToLower(strings.TrimSpace(*language)), GithubPat: pat, } + skillsSelection, err := parseSkillSelections(*skills) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 2 + } + opt.Skills = skillsSelection + opt.SkillsSource = strings.TrimSpace(*skillsSource) + opt.SkillsRef = strings.TrimSpace(*skillsRef) + opt.SkillsLink = *skillsLink + opt.SkillsDryRun = *skillsDryRun extrasSelections, err := parseExtrasSelections(*extras) if err != nil { fmt.Fprintln(os.Stderr, err) @@ -274,6 +293,48 @@ func runInstall(ctx context.Context, args []string) int { return runInstaller(ctx, ui.ModeInstall, &opt, *logToFile, *cliMode, *quiet) } +func validateSkillsCLIOptions(skills string, cliMode bool, link bool, source string, ref string) error { + if strings.TrimSpace(skills) == "" { + return nil + } + if !cliMode { + return fmt.Errorf("--skills is currently supported only with --cli; TUI skill selection is not implemented yet") + } + if link && strings.TrimSpace(source) == "" { + return fmt.Errorf("--skills-link requires a local --skills-source path") + } + if link && strings.TrimSpace(ref) != "" { + return fmt.Errorf("--skills-ref conflicts with --skills-link; symlink mode must point at the current local checkout") + } + return nil +} + +func parseSkillSelections(raw string) ([]string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + seen := map[string]struct{}{} + for _, part := range parts { + name := strings.TrimSpace(part) + if name == "" { + continue + } + if strings.ContainsAny(name, "/\\") { + return nil, fmt.Errorf("invalid --skills value: %q", name) + } + key := strings.ToLower(name) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, name) + } + return out, nil +} + func parseExtrasSelections(raw string) ([]domain.ExtrasSelection, error) { raw = strings.TrimSpace(raw) if raw == "" { @@ -314,7 +375,8 @@ func splitInstallArgs(args []string) (installDir string, flagArgs []string, err expectsValue := func(flag string) bool { switch flag { case "branch", "preset", "db-type", "db-host", "db-port", "db-name", "db-user", "db-password", - "admin-username", "admin-email", "admin-password", "admin-directory", "language", "github-pat", "github_pat", "extras": + "admin-username", "admin-email", "admin-password", "admin-directory", "language", "github-pat", "github_pat", + "extras", "skills", "skills-source", "skills-ref": return true default: return false @@ -505,5 +567,10 @@ func printUsage() { fmt.Println(" --composer-clear-cache Clear Composer cache before install") fmt.Println(" --composer-update Use composer update instead of install during setup") fmt.Println(" --cli Run in non-interactive CLI mode (no TUI)") + fmt.Println(" --skills= CLI-only optional EVO skills install (default, none, or comma list)") + fmt.Println(" --skills-source= Local evo-skills source checkout") + fmt.Println(" --skills-ref= Record source git ref/hash for copy installs") + fmt.Println(" --skills-link Symlink skills from local source") + fmt.Println(" --skills-dry-run Plan skills install without writing files") fmt.Println(" --quiet Reduce CLI output (warnings/errors only)") } diff --git a/cmd/evo/main_test.go b/cmd/evo/main_test.go index 30eaefb..f2f228f 100644 --- a/cmd/evo/main_test.go +++ b/cmd/evo/main_test.go @@ -26,6 +26,40 @@ func TestSplitInstallArgsKeepsPresetFlags(t *testing.T) { } } +func TestSplitInstallArgsKeepsSkillsFlags(t *testing.T) { + installDir, flags, err := splitInstallArgs([]string{ + "/tmp/site", + "--cli", + "--skills=evo-skill-creator", + "--skills-source", + "/tmp/evo-skills", + "--skills-ref=main", + "--skills-dry-run", + }) + if err != nil { + t.Fatalf("splitInstallArgs returned error: %v", err) + } + if installDir != "/tmp/site" { + t.Fatalf("installDir = %q, want /tmp/site", installDir) + } + want := []string{ + "--cli", + "--skills=evo-skill-creator", + "--skills-source", + "/tmp/evo-skills", + "--skills-ref=main", + "--skills-dry-run", + } + if len(flags) != len(want) { + t.Fatalf("flags = %#v, want %#v", flags, want) + } + for i := range want { + if flags[i] != want[i] { + t.Fatalf("flags[%d] = %q, want %q", i, flags[i], want[i]) + } + } +} + func TestSplitInstallArgsRequiresPresetValue(t *testing.T) { _, _, err := splitInstallArgs([]string{"/tmp/site", "--preset"}) if err == nil { @@ -71,3 +105,43 @@ func TestParseExtrasSelectionsKeepsLegacyStoreID(t *testing.T) { t.Fatalf("expected managed name selection, got %#v", selections[1]) } } + +func TestParseSkillSelections(t *testing.T) { + selections, err := parseSkillSelections("default,evo-skill-creator,evo-skill-creator") + if err != nil { + t.Fatalf("parseSkillSelections returned error: %v", err) + } + want := []string{"default", "evo-skill-creator"} + if len(selections) != len(want) { + t.Fatalf("selections = %#v, want %#v", selections, want) + } + for i := range want { + if selections[i] != want[i] { + t.Fatalf("selections[%d] = %q, want %q", i, selections[i], want[i]) + } + } +} + +func TestParseSkillSelectionsRejectsPaths(t *testing.T) { + if _, err := parseSkillSelections("../bad"); err == nil { + t.Fatal("parseSkillSelections returned nil error for path-like skill") + } +} + +func TestValidateSkillsRequiresCLI(t *testing.T) { + if err := validateSkillsCLIOptions("evo-skill-creator", false, false, "", ""); err == nil { + t.Fatal("validateSkillsCLIOptions returned nil error without --cli") + } +} + +func TestValidateSkillsLinkRequiresSource(t *testing.T) { + if err := validateSkillsCLIOptions("evo-skill-creator", true, true, "", ""); err == nil { + t.Fatal("validateSkillsCLIOptions returned nil error for --skills-link without source") + } +} + +func TestValidateSkillsLinkConflictsWithRef(t *testing.T) { + if err := validateSkillsCLIOptions("evo-skill-creator", true, true, "/tmp/evo-skills", "main"); err == nil { + t.Fatal("validateSkillsCLIOptions returned nil error for link/ref conflict") + } +} diff --git a/internal/engine/install/engine.go b/internal/engine/install/engine.go index ea78938..301e6bf 100644 --- a/internal/engine/install/engine.go +++ b/internal/engine/install/engine.go @@ -51,6 +51,12 @@ type Options struct { GithubPat string Extras []domain.ExtrasSelection + + Skills []string + SkillsSource string + SkillsRef string + SkillsLink bool + SkillsDryRun bool } type Engine struct { @@ -1025,6 +1031,7 @@ func (e *Engine) Run(ctx context.Context, ch chan<- domain.Event, actions <-chan }) } e.maybeRunExtras(ctx, emit, actions, workDir, requiredExtras) + e.maybeRunSkillsInstall(ctx, emit, workDir) e.cleanupExtrasRuntimeArtifacts(emit, workDir) }() } diff --git a/internal/engine/install/skills.go b/internal/engine/install/skills.go new file mode 100644 index 0000000..f50a776 --- /dev/null +++ b/internal/engine/install/skills.go @@ -0,0 +1,631 @@ +package install + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/evolution-cms/installer/internal/domain" +) + +const ( + skillsStepID = "skills" + skillsManifestPath = "manifests/evo-skills.manifest.json" + skillsStateSchema = "evo.skills.install-state.v1" + skillsManifestVersion = "evo.skills.manifest.v1" +) + +type skillsManifest struct { + SchemaVersion string `json:"schema_version"` + InstallRoot string `json:"install_root"` + Lockfile string `json:"lockfile"` + DefaultInstall []string `json:"default_install"` + Skills []skillsManifestEntry `json:"skills"` +} + +type skillsManifestEntry struct { + Name string `json:"name"` + SourcePath string `json:"source_path"` + SkillFile string `json:"skill_file"` + InstallTarget string `json:"install_target"` + ContentHash string `json:"content_hash"` + ModeSupport []string `json:"mode_support"` +} + +type skillsInstallPlan struct { + ProjectRoot string + SourceRoot string + SourceRef string + ManifestPath string + InstallRoot string + LockfilePath string + Mode string + DryRun bool + Selected []string + InstalledSkills []skillsInstalledItem + Operations []skillsInstallOperation +} + +type skillsInstalledItem struct { + Name string `json:"name"` + SourcePath string `json:"source_path"` + TargetPath string `json:"target_path"` + ContentHash string `json:"content_hash"` + Mode string `json:"mode"` + Status string `json:"status"` +} + +type skillsInstallOperation struct { + Kind string `json:"kind"` + Source string `json:"source,omitempty"` + Target string `json:"target,omitempty"` + Ownership string `json:"ownership,omitempty"` + Status string `json:"status"` +} + +type skillsInstallState struct { + SchemaVersion string `json:"schema_version"` + InstalledAt string `json:"installed_at"` + ProjectRoot string `json:"project_root"` + SkillsRoot string `json:"skills_root"` + Mode string `json:"mode"` + Source skillsInstallStateSource `json:"source"` + InstalledSkills []skillsInstalledItem `json:"installed_skills"` + Operations []skillsInstallOperation `json:"operations,omitempty"` +} + +type skillsInstallStateSource struct { + Type string `json:"type"` + Path string `json:"path,omitempty"` + Ref string `json:"ref,omitempty"` + Commit string `json:"commit,omitempty"` + Manifest string `json:"manifest,omitempty"` +} + +func (e *Engine) maybeRunSkillsInstall(ctx context.Context, emit func(domain.Event) bool, workDir string) { + if len(e.opt.Skills) == 0 { + return + } + + _ = emit(domain.Event{ + Type: domain.EventStepStart, + StepID: skillsStepID, + Source: "skills", + Severity: domain.SeverityInfo, + Payload: domain.StepStartPayload{ + Label: "Install EVO Skills", + Index: 8, + Total: 8, + }, + }) + + if err := ctx.Err(); err != nil { + _ = emit(domain.Event{ + Type: domain.EventError, + StepID: skillsStepID, + Source: "skills", + Severity: domain.SeverityError, + Payload: domain.LogPayload{ + Message: "EVO skills install cancelled.", + Fields: map[string]string{"error": err.Error()}, + }, + }) + _ = emit(domain.Event{ + Type: domain.EventStepDone, + StepID: skillsStepID, + Source: "skills", + Severity: domain.SeverityError, + Payload: domain.StepDonePayload{OK: false}, + }) + return + } + + plan, err := planSkillsInstall(e.opt, workDir) + if err != nil { + _ = emit(domain.Event{ + Type: domain.EventError, + StepID: skillsStepID, + Source: "skills", + Severity: domain.SeverityError, + Payload: domain.LogPayload{ + Message: "EVO skills install failed.", + Fields: map[string]string{"error": err.Error()}, + }, + }) + _ = emit(domain.Event{ + Type: domain.EventStepDone, + StepID: skillsStepID, + Source: "skills", + Severity: domain.SeverityError, + Payload: domain.StepDonePayload{OK: false}, + }) + return + } + + if len(plan.Selected) == 0 { + _ = emit(domain.Event{ + Type: domain.EventLog, + StepID: skillsStepID, + Source: "skills", + Severity: domain.SeverityInfo, + Payload: domain.LogPayload{ + Message: "EVO skills install skipped (--skills=none).", + }, + }) + _ = emit(domain.Event{ + Type: domain.EventStepDone, + StepID: skillsStepID, + Source: "skills", + Severity: domain.SeverityInfo, + Payload: domain.StepDonePayload{OK: true}, + }) + return + } + + _ = emit(domain.Event{ + Type: domain.EventLog, + StepID: skillsStepID, + Source: "skills", + Severity: domain.SeverityInfo, + Payload: domain.LogPayload{ + Message: fmt.Sprintf("Planned EVO skills install: %s (%s mode).", strings.Join(plan.Selected, ", "), plan.Mode), + }, + }) + + if !plan.DryRun { + if err := applySkillsInstallPlan(plan); err != nil { + _ = emit(domain.Event{ + Type: domain.EventError, + StepID: skillsStepID, + Source: "skills", + Severity: domain.SeverityError, + Payload: domain.LogPayload{ + Message: "EVO skills install failed.", + Fields: map[string]string{"error": err.Error()}, + }, + }) + _ = emit(domain.Event{ + Type: domain.EventStepDone, + StepID: skillsStepID, + Source: "skills", + Severity: domain.SeverityError, + Payload: domain.StepDonePayload{OK: false}, + }) + return + } + } + + msg := "EVO skills dry-run completed; no files written." + if !plan.DryRun { + msg = "EVO skills installed and lockfile written." + } + _ = emit(domain.Event{ + Type: domain.EventLog, + StepID: skillsStepID, + Source: "skills", + Severity: domain.SeverityInfo, + Payload: domain.LogPayload{ + Message: msg, + }, + }) + _ = emit(domain.Event{ + Type: domain.EventStepDone, + StepID: skillsStepID, + Source: "skills", + Severity: domain.SeverityInfo, + Payload: domain.StepDonePayload{OK: true}, + }) +} + +func planSkillsInstall(opt Options, workDir string) (skillsInstallPlan, error) { + mode := "copy" + if opt.SkillsLink { + mode = "link" + } + + projectRoot := absDir(workDir) + sourceRoot := absDir(strings.TrimSpace(opt.SkillsSource)) + if len(opt.Skills) == 0 || isSkillsNone(opt.Skills) { + return skillsInstallPlan{ + ProjectRoot: projectRoot, + Mode: mode, + DryRun: opt.SkillsDryRun, + }, nil + } + if sourceRoot == "" { + return skillsInstallPlan{}, errors.New("--skills-source is required for CLI skills install MVP") + } + manifestPath := filepath.Join(sourceRoot, skillsManifestPath) + manifest, err := readSkillsManifest(manifestPath) + if err != nil { + return skillsInstallPlan{}, err + } + if strings.TrimSpace(manifest.InstallRoot) == "" { + manifest.InstallRoot = "core/custom/skills" + } + if strings.TrimSpace(manifest.Lockfile) == "" { + manifest.Lockfile = filepath.Join(manifest.InstallRoot, ".evo-skills.lock.json") + } + + selected, err := resolveSkillsSelection(opt.Skills, manifest) + if err != nil { + return skillsInstallPlan{}, err + } + if len(selected) == 0 { + return skillsInstallPlan{ + ProjectRoot: projectRoot, + SourceRoot: sourceRoot, + ManifestPath: manifestPath, + Mode: mode, + DryRun: opt.SkillsDryRun, + }, nil + } + + installRoot := filepath.Join(projectRoot, filepath.FromSlash(manifest.InstallRoot)) + lockfilePath := filepath.Join(projectRoot, filepath.FromSlash(manifest.Lockfile)) + previousState, _ := readSkillsInstallState(lockfilePath) + + byName := map[string]skillsManifestEntry{} + for _, item := range manifest.Skills { + byName[item.Name] = item + } + + plan := skillsInstallPlan{ + ProjectRoot: projectRoot, + SourceRoot: sourceRoot, + SourceRef: strings.TrimSpace(opt.SkillsRef), + ManifestPath: manifestPath, + InstallRoot: installRoot, + LockfilePath: lockfilePath, + Mode: mode, + DryRun: opt.SkillsDryRun, + Selected: selected, + Operations: []skillsInstallOperation{ + {Kind: "mkdir", Target: filepath.ToSlash(manifest.InstallRoot), Ownership: "managed", Status: "planned"}, + }, + } + + for _, name := range selected { + item, ok := byName[name] + if !ok { + return skillsInstallPlan{}, fmt.Errorf("selected skill %q is missing from manifest", name) + } + if !supportsSkillsMode(item.ModeSupport, mode) { + return skillsInstallPlan{}, fmt.Errorf("skill %q does not support %s mode", name, mode) + } + sourceDir := filepath.Join(sourceRoot, filepath.FromSlash(item.SourcePath)) + sourceFile := filepath.Join(sourceRoot, filepath.FromSlash(item.SkillFile)) + if strings.TrimSpace(item.InstallTarget) == "" { + item.InstallTarget = filepath.ToSlash(filepath.Join(manifest.InstallRoot, item.Name)) + } + targetDir := filepath.Join(projectRoot, filepath.FromSlash(item.InstallTarget)) + + if st, err := os.Stat(sourceDir); err != nil || !st.IsDir() { + return skillsInstallPlan{}, fmt.Errorf("skill %q source directory is not readable: %s", name, sourceDir) + } + if st, err := os.Stat(sourceFile); err != nil || st.IsDir() { + return skillsInstallPlan{}, fmt.Errorf("skill %q SKILL.md is not readable: %s", name, sourceFile) + } + hash, err := sha256File(sourceFile) + if err != nil { + return skillsInstallPlan{}, fmt.Errorf("unable to hash skill %q: %w", name, err) + } + if expected := strings.TrimSpace(item.ContentHash); expected != "" && expected != hash { + return skillsInstallPlan{}, fmt.Errorf("skill %q hash mismatch: manifest %s, actual %s", name, expected, hash) + } + + ownership := "managed" + if _, err := os.Lstat(targetDir); err == nil { + if mode == "link" && symlinkPointsTo(targetDir, sourceDir) { + plan.Operations = append(plan.Operations, skillsInstallOperation{ + Kind: "skip", + Source: filepath.ToSlash(sourceDir), + Target: filepath.ToSlash(item.InstallTarget), + Ownership: "managed", + Status: "planned", + }) + plan.InstalledSkills = append(plan.InstalledSkills, skillsInstalledItem{ + Name: name, + SourcePath: filepath.ToSlash(item.SourcePath), + TargetPath: filepath.ToSlash(item.InstallTarget), + ContentHash: hash, + Mode: mode, + Status: "installed", + }) + continue + } + managed := isManagedSkillTarget(previousState, name, item.InstallTarget) + if !managed { + ownership = "unmanaged" + if !opt.Force { + return skillsInstallPlan{}, fmt.Errorf("target already exists for skill %q (%s); use --force to replace unmanaged files", name, targetDir) + } + } + } else if !errors.Is(err, os.ErrNotExist) { + return skillsInstallPlan{}, fmt.Errorf("unable to inspect target for skill %q: %w", name, err) + } + + plan.Operations = append(plan.Operations, skillsInstallOperation{ + Kind: mode, + Source: filepath.ToSlash(sourceDir), + Target: filepath.ToSlash(item.InstallTarget), + Ownership: ownership, + Status: "planned", + }) + plan.InstalledSkills = append(plan.InstalledSkills, skillsInstalledItem{ + Name: name, + SourcePath: filepath.ToSlash(item.SourcePath), + TargetPath: filepath.ToSlash(item.InstallTarget), + ContentHash: hash, + Mode: mode, + Status: "installed", + }) + } + plan.Operations = append(plan.Operations, skillsInstallOperation{ + Kind: "write-lockfile", + Target: filepath.ToSlash(manifest.Lockfile), + Ownership: "managed", + Status: "planned", + }) + return plan, nil +} + +func applySkillsInstallPlan(plan skillsInstallPlan) error { + if len(plan.Selected) == 0 { + return nil + } + if err := os.MkdirAll(plan.InstallRoot, 0o755); err != nil { + return err + } + for _, op := range plan.Operations { + switch op.Kind { + case "copy": + source := filepath.FromSlash(op.Source) + target := filepath.Join(plan.ProjectRoot, filepath.FromSlash(op.Target)) + if err := replaceTarget(target); err != nil { + return err + } + if err := copyDir(source, target); err != nil { + return err + } + case "link": + source := filepath.FromSlash(op.Source) + target := filepath.Join(plan.ProjectRoot, filepath.FromSlash(op.Target)) + if err := replaceTarget(target); err != nil { + return err + } + if err := os.Symlink(source, target); err != nil { + return err + } + } + } + state := skillsInstallState{ + SchemaVersion: skillsStateSchema, + InstalledAt: time.Now().UTC().Format(time.RFC3339), + ProjectRoot: plan.ProjectRoot, + SkillsRoot: filepath.ToSlash(relOrSelf(plan.ProjectRoot, plan.InstallRoot)), + Mode: plan.Mode, + Source: skillsInstallStateSource{ + Type: "local-path", + Path: plan.SourceRoot, + Ref: plan.SourceRef, + Manifest: plan.ManifestPath, + }, + InstalledSkills: plan.InstalledSkills, + Operations: appliedSkillsOperations(plan.Operations), + } + raw, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(plan.LockfilePath), 0o755); err != nil { + return err + } + return os.WriteFile(plan.LockfilePath, append(raw, '\n'), 0o644) +} + +func readSkillsManifest(path string) (skillsManifest, error) { + raw, err := os.ReadFile(path) + if err != nil { + return skillsManifest{}, fmt.Errorf("unable to read skills manifest: %w", err) + } + var manifest skillsManifest + if err := json.Unmarshal(raw, &manifest); err != nil { + return skillsManifest{}, fmt.Errorf("invalid skills manifest: %w", err) + } + if strings.TrimSpace(manifest.SchemaVersion) != skillsManifestVersion { + return skillsManifest{}, fmt.Errorf("unsupported skills manifest version %q", manifest.SchemaVersion) + } + return manifest, nil +} + +func readSkillsInstallState(path string) (skillsInstallState, error) { + raw, err := os.ReadFile(path) + if err != nil { + return skillsInstallState{}, err + } + var state skillsInstallState + if err := json.Unmarshal(raw, &state); err != nil { + return skillsInstallState{}, err + } + return state, nil +} + +func resolveSkillsSelection(raw []string, manifest skillsManifest) ([]string, error) { + if len(raw) == 0 || isSkillsNone(raw) { + return nil, nil + } + if len(raw) == 1 && strings.EqualFold(strings.TrimSpace(raw[0]), "default") { + return uniqueSkillNames(manifest.DefaultInstall), nil + } + selected := uniqueSkillNames(raw) + for _, name := range selected { + if strings.EqualFold(name, "default") || strings.EqualFold(name, "none") { + return nil, fmt.Errorf("--skills value %q cannot be mixed with explicit skills", name) + } + } + return selected, nil +} + +func uniqueSkillNames(raw []string) []string { + out := make([]string, 0, len(raw)) + seen := map[string]struct{}{} + for _, item := range raw { + item = strings.TrimSpace(item) + if item == "" { + continue + } + key := strings.ToLower(item) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, item) + } + return out +} + +func isSkillsNone(raw []string) bool { + return len(raw) == 1 && strings.EqualFold(strings.TrimSpace(raw[0]), "none") +} + +func supportsSkillsMode(modes []string, mode string) bool { + if len(modes) == 0 { + return mode == "copy" + } + for _, item := range modes { + if strings.EqualFold(strings.TrimSpace(item), mode) { + return true + } + } + return false +} + +func sha256File(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return "sha256:" + hex.EncodeToString(h.Sum(nil)), nil +} + +func isManagedSkillTarget(state skillsInstallState, name string, target string) bool { + for _, item := range state.InstalledSkills { + if item.Name == name || filepath.ToSlash(item.TargetPath) == filepath.ToSlash(target) { + return true + } + } + return false +} + +func symlinkPointsTo(target string, source string) bool { + dest, err := os.Readlink(target) + if err != nil { + return false + } + if !filepath.IsAbs(dest) { + dest = filepath.Join(filepath.Dir(target), dest) + } + return absDir(dest) == absDir(source) +} + +func replaceTarget(path string) error { + if _, err := os.Lstat(path); err == nil { + return os.RemoveAll(path) + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + return nil +} + +func copyDir(source string, target string) error { + return filepath.WalkDir(source, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(source, path) + if err != nil { + return err + } + if rel == "." { + return os.MkdirAll(target, 0o755) + } + if shouldSkipSkillCopyEntry(rel, d) { + if d.IsDir() { + return filepath.SkipDir + } + return nil + } + dest := filepath.Join(target, rel) + if d.IsDir() { + return os.MkdirAll(dest, 0o755) + } + info, err := d.Info() + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return nil + } + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return err + } + return copyFile(path, dest, info.Mode().Perm()) + }) +} + +func shouldSkipSkillCopyEntry(rel string, d os.DirEntry) bool { + name := d.Name() + if name == ".git" || name == "node_modules" || name == "vendor" || name == "dist" || name == "build" { + return true + } + _ = rel + return false +} + +func copyFile(source string, target string, perm os.FileMode) error { + in, err := os.Open(source) + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, perm) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + _ = out.Close() + return err + } + return out.Close() +} + +func appliedSkillsOperations(ops []skillsInstallOperation) []skillsInstallOperation { + out := make([]skillsInstallOperation, 0, len(ops)) + for _, op := range ops { + op.Status = "applied" + out = append(out, op) + } + return out +} + +func relOrSelf(base string, path string) string { + rel, err := filepath.Rel(base, path) + if err != nil || strings.HasPrefix(rel, "..") { + return path + } + return rel +} diff --git a/internal/engine/install/skills_test.go b/internal/engine/install/skills_test.go new file mode 100644 index 0000000..2cb13ed --- /dev/null +++ b/internal/engine/install/skills_test.go @@ -0,0 +1,208 @@ +package install + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestPlanSkillsInstallDryRunWritesNothing(t *testing.T) { + t.Parallel() + + sourceRoot := makeSkillsSource(t) + projectRoot := t.TempDir() + + plan, err := planSkillsInstall(Options{ + Skills: []string{"default"}, + SkillsSource: sourceRoot, + SkillsDryRun: true, + }, projectRoot) + if err != nil { + t.Fatalf("planSkillsInstall returned error: %v", err) + } + if !plan.DryRun { + t.Fatal("expected dry-run plan") + } + if len(plan.Selected) != 1 || plan.Selected[0] != "evo-skill-creator" { + t.Fatalf("selected = %#v", plan.Selected) + } + if _, err := os.Lstat(filepath.Join(projectRoot, "core", "custom", "skills", "evo-skill-creator")); !os.IsNotExist(err) { + t.Fatalf("dry-run should not create target, stat err=%v", err) + } +} + +func TestApplySkillsInstallCopyWritesSkillAndLockfile(t *testing.T) { + t.Parallel() + + sourceRoot := makeSkillsSource(t) + projectRoot := t.TempDir() + + plan, err := planSkillsInstall(Options{ + Skills: []string{"evo-skill-creator"}, + SkillsSource: sourceRoot, + SkillsRef: "main", + }, projectRoot) + if err != nil { + t.Fatalf("planSkillsInstall returned error: %v", err) + } + if err := applySkillsInstallPlan(plan); err != nil { + t.Fatalf("applySkillsInstallPlan returned error: %v", err) + } + + targetSkill := filepath.Join(projectRoot, "core", "custom", "skills", "evo-skill-creator", "SKILL.md") + if raw, err := os.ReadFile(targetSkill); err != nil || !strings.Contains(string(raw), "evo-skill-creator") { + t.Fatalf("expected copied SKILL.md, err=%v raw=%q", err, raw) + } + lockPath := filepath.Join(projectRoot, "core", "custom", "skills", ".evo-skills.lock.json") + raw, err := os.ReadFile(lockPath) + if err != nil { + t.Fatalf("expected lockfile: %v", err) + } + var state skillsInstallState + if err := json.Unmarshal(raw, &state); err != nil { + t.Fatalf("invalid lockfile JSON: %v", err) + } + if state.SchemaVersion != skillsStateSchema { + t.Fatalf("schema version = %q", state.SchemaVersion) + } + if state.Mode != "copy" { + t.Fatalf("mode = %q, want copy", state.Mode) + } + if state.Source.Ref != "main" { + t.Fatalf("source ref = %q, want main", state.Source.Ref) + } + if len(state.InstalledSkills) != 1 || state.InstalledSkills[0].Name != "evo-skill-creator" { + t.Fatalf("installed skills = %#v", state.InstalledSkills) + } +} + +func TestApplySkillsInstallLinkCreatesSymlink(t *testing.T) { + t.Parallel() + + sourceRoot := makeSkillsSource(t) + projectRoot := t.TempDir() + + plan, err := planSkillsInstall(Options{ + Skills: []string{"evo-skill-creator"}, + SkillsSource: sourceRoot, + SkillsLink: true, + }, projectRoot) + if err != nil { + t.Fatalf("planSkillsInstall returned error: %v", err) + } + if plan.Mode != "link" { + t.Fatalf("mode = %q, want link", plan.Mode) + } + if err := applySkillsInstallPlan(plan); err != nil { + t.Fatalf("applySkillsInstallPlan returned error: %v", err) + } + + target := filepath.Join(projectRoot, "core", "custom", "skills", "evo-skill-creator") + info, err := os.Lstat(target) + if err != nil { + t.Fatalf("expected target symlink: %v", err) + } + if info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("expected symlink, mode=%s", info.Mode()) + } +} + +func TestPlanSkillsInstallMissingSkillFails(t *testing.T) { + t.Parallel() + + _, err := planSkillsInstall(Options{ + Skills: []string{"missing-skill"}, + SkillsSource: makeSkillsSource(t), + }, t.TempDir()) + if err == nil { + t.Fatal("planSkillsInstall returned nil error for missing skill") + } +} + +func TestPlanSkillsInstallExistingUnmanagedTargetRequiresForce(t *testing.T) { + t.Parallel() + + projectRoot := t.TempDir() + target := filepath.Join(projectRoot, "core", "custom", "skills", "evo-skill-creator") + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(target, "SKILL.md"), []byte("unmanaged"), 0o644); err != nil { + t.Fatal(err) + } + + _, err := planSkillsInstall(Options{ + Skills: []string{"evo-skill-creator"}, + SkillsSource: makeSkillsSource(t), + }, projectRoot) + if err == nil { + t.Fatal("planSkillsInstall returned nil error for existing unmanaged target") + } +} + +func TestPlanSkillsInstallNoneIsNoop(t *testing.T) { + t.Parallel() + + plan, err := planSkillsInstall(Options{ + Skills: []string{"none"}, + }, t.TempDir()) + if err != nil { + t.Fatalf("planSkillsInstall returned error: %v", err) + } + if len(plan.Selected) != 0 || len(plan.Operations) != 0 { + t.Fatalf("expected no-op plan, got selected=%#v operations=%#v", plan.Selected, plan.Operations) + } +} + +func makeSkillsSource(t *testing.T) string { + t.Helper() + + root := t.TempDir() + skillDir := filepath.Join(root, "skills", "evo-skill-creator") + if err := os.MkdirAll(filepath.Join(skillDir, "agents"), 0o755); err != nil { + t.Fatal(err) + } + skillBody := "---\nname: evo-skill-creator\ndescription: Test skill.\n---\n\n# evo-skill-creator\n" + skillPath := filepath.Join(skillDir, "SKILL.md") + if err := os.WriteFile(skillPath, []byte(skillBody), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(skillDir, "agents", "openai.yaml"), []byte("display_name: Test\n"), 0o644); err != nil { + t.Fatal(err) + } + hash, err := sha256File(skillPath) + if err != nil { + t.Fatal(err) + } + + manifestDir := filepath.Join(root, "manifests") + if err := os.MkdirAll(manifestDir, 0o755); err != nil { + t.Fatal(err) + } + manifest := skillsManifest{ + SchemaVersion: skillsManifestVersion, + InstallRoot: "core/custom/skills", + Lockfile: "core/custom/skills/.evo-skills.lock.json", + DefaultInstall: []string{"evo-skill-creator"}, + Skills: []skillsManifestEntry{ + { + Name: "evo-skill-creator", + SourcePath: "skills/evo-skill-creator", + SkillFile: "skills/evo-skill-creator/SKILL.md", + InstallTarget: "core/custom/skills/evo-skill-creator", + ContentHash: hash, + ModeSupport: []string{"copy", "link"}, + }, + }, + } + raw, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(manifestDir, "evo-skills.manifest.json"), append(raw, '\n'), 0o644); err != nil { + t.Fatal(err) + } + return root +} From 8f50dd2b52d9c05d73755628dc5d73a3bd79576b Mon Sep 17 00:00:00 2001 From: middleDuckAi <269711613+middleDuckAi@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:34:28 +0700 Subject: [PATCH 2/3] fix(skills): verify manifest file hashes --- internal/engine/install/skills.go | 63 +++++++++++++++++----- internal/engine/install/skills_test.go | 73 +++++++++++++++++++++++++- 2 files changed, 123 insertions(+), 13 deletions(-) diff --git a/internal/engine/install/skills.go b/internal/engine/install/skills.go index f50a776..e9f1b10 100644 --- a/internal/engine/install/skills.go +++ b/internal/engine/install/skills.go @@ -32,12 +32,13 @@ type skillsManifest struct { } type skillsManifestEntry struct { - Name string `json:"name"` - SourcePath string `json:"source_path"` - SkillFile string `json:"skill_file"` - InstallTarget string `json:"install_target"` - ContentHash string `json:"content_hash"` - ModeSupport []string `json:"mode_support"` + Name string `json:"name"` + SourcePath string `json:"source_path"` + SkillFile string `json:"skill_file"` + InstallTarget string `json:"install_target"` + ContentHash string `json:"content_hash"` + FileHashes map[string]string `json:"file_hashes,omitempty"` + ModeSupport []string `json:"mode_support"` } type skillsInstallPlan struct { @@ -55,12 +56,13 @@ type skillsInstallPlan struct { } type skillsInstalledItem struct { - Name string `json:"name"` - SourcePath string `json:"source_path"` - TargetPath string `json:"target_path"` - ContentHash string `json:"content_hash"` - Mode string `json:"mode"` - Status string `json:"status"` + Name string `json:"name"` + SourcePath string `json:"source_path"` + TargetPath string `json:"target_path"` + ContentHash string `json:"content_hash"` + FileHashes map[string]string `json:"file_hashes,omitempty"` + Mode string `json:"mode"` + Status string `json:"status"` } type skillsInstallOperation struct { @@ -321,6 +323,10 @@ func planSkillsInstall(opt Options, workDir string) (skillsInstallPlan, error) { if expected := strings.TrimSpace(item.ContentHash); expected != "" && expected != hash { return skillsInstallPlan{}, fmt.Errorf("skill %q hash mismatch: manifest %s, actual %s", name, expected, hash) } + fileHashes, err := verifySkillFileHashes(sourceDir, item) + if err != nil { + return skillsInstallPlan{}, err + } ownership := "managed" if _, err := os.Lstat(targetDir); err == nil { @@ -337,6 +343,7 @@ func planSkillsInstall(opt Options, workDir string) (skillsInstallPlan, error) { SourcePath: filepath.ToSlash(item.SourcePath), TargetPath: filepath.ToSlash(item.InstallTarget), ContentHash: hash, + FileHashes: fileHashes, Mode: mode, Status: "installed", }) @@ -365,6 +372,7 @@ func planSkillsInstall(opt Options, workDir string) (skillsInstallPlan, error) { SourcePath: filepath.ToSlash(item.SourcePath), TargetPath: filepath.ToSlash(item.InstallTarget), ContentHash: hash, + FileHashes: fileHashes, Mode: mode, Status: "installed", }) @@ -522,6 +530,37 @@ func sha256File(path string) (string, error) { return "sha256:" + hex.EncodeToString(h.Sum(nil)), nil } +func verifySkillFileHashes(sourceDir string, item skillsManifestEntry) (map[string]string, error) { + if len(item.FileHashes) == 0 { + return nil, nil + } + out := make(map[string]string, len(item.FileHashes)) + for relativePath, expectedHash := range item.FileHashes { + relativePath = strings.TrimSpace(relativePath) + if relativePath == "" { + return nil, fmt.Errorf("skill %q declares an empty file_hashes path", item.Name) + } + cleanPath := filepath.Clean(filepath.FromSlash(relativePath)) + if filepath.IsAbs(cleanPath) || cleanPath == ".." || strings.HasPrefix(cleanPath, ".."+string(filepath.Separator)) { + return nil, fmt.Errorf("skill %q declares unsafe file_hashes path %q", item.Name, relativePath) + } + filePath := filepath.Join(sourceDir, cleanPath) + stat, err := os.Stat(filePath) + if err != nil || stat.IsDir() { + return nil, fmt.Errorf("skill %q declared file is not readable: %s", item.Name, filepath.ToSlash(cleanPath)) + } + actualHash, err := sha256File(filePath) + if err != nil { + return nil, fmt.Errorf("unable to hash skill %q declared file %q: %w", item.Name, filepath.ToSlash(cleanPath), err) + } + if expected := strings.TrimSpace(expectedHash); expected != "" && expected != actualHash { + return nil, fmt.Errorf("skill %q file hash mismatch for %q: manifest %s, actual %s", item.Name, filepath.ToSlash(cleanPath), expected, actualHash) + } + out[filepath.ToSlash(cleanPath)] = actualHash + } + return out, nil +} + func isManagedSkillTarget(state skillsInstallState, name string, target string) bool { for _, item := range state.InstalledSkills { if item.Name == name || filepath.ToSlash(item.TargetPath) == filepath.ToSlash(target) { diff --git a/internal/engine/install/skills_test.go b/internal/engine/install/skills_test.go index 2cb13ed..92ec63a 100644 --- a/internal/engine/install/skills_test.go +++ b/internal/engine/install/skills_test.go @@ -76,6 +76,13 @@ func TestApplySkillsInstallCopyWritesSkillAndLockfile(t *testing.T) { if len(state.InstalledSkills) != 1 || state.InstalledSkills[0].Name != "evo-skill-creator" { t.Fatalf("installed skills = %#v", state.InstalledSkills) } + if got := state.InstalledSkills[0].FileHashes["agents/openai.yaml"]; got == "" { + t.Fatalf("expected file hash evidence for agents/openai.yaml, got %#v", state.InstalledSkills[0].FileHashes) + } + targetAgent := filepath.Join(projectRoot, "core", "custom", "skills", "evo-skill-creator", "agents", "openai.yaml") + if raw, err := os.ReadFile(targetAgent); err != nil || !strings.Contains(string(raw), "display_name") { + t.Fatalf("expected copied agents/openai.yaml, err=%v raw=%q", err, raw) + } } func TestApplySkillsInstallLinkCreatesSymlink(t *testing.T) { @@ -142,6 +149,40 @@ func TestPlanSkillsInstallExistingUnmanagedTargetRequiresForce(t *testing.T) { } } +func TestPlanSkillsInstallDeclaredFileHashMismatchFails(t *testing.T) { + t.Parallel() + + sourceRoot := makeSkillsSource(t) + rewriteSkillsManifest(t, sourceRoot, func(manifest *skillsManifest) { + manifest.Skills[0].FileHashes["agents/openai.yaml"] = "sha256:bad" + }) + + _, err := planSkillsInstall(Options{ + Skills: []string{"evo-skill-creator"}, + SkillsSource: sourceRoot, + }, t.TempDir()) + if err == nil || !strings.Contains(err.Error(), "file hash mismatch") { + t.Fatalf("expected file hash mismatch error, got %v", err) + } +} + +func TestPlanSkillsInstallDeclaredFileMissingFails(t *testing.T) { + t.Parallel() + + sourceRoot := makeSkillsSource(t) + if err := os.Remove(filepath.Join(sourceRoot, "skills", "evo-skill-creator", "agents", "openai.yaml")); err != nil { + t.Fatal(err) + } + + _, err := planSkillsInstall(Options{ + Skills: []string{"evo-skill-creator"}, + SkillsSource: sourceRoot, + }, t.TempDir()) + if err == nil || !strings.Contains(err.Error(), "declared file is not readable") { + t.Fatalf("expected missing declared file error, got %v", err) + } +} + func TestPlanSkillsInstallNoneIsNoop(t *testing.T) { t.Parallel() @@ -176,6 +217,10 @@ func makeSkillsSource(t *testing.T) string { if err != nil { t.Fatal(err) } + agentHash, err := sha256File(filepath.Join(skillDir, "agents", "openai.yaml")) + if err != nil { + t.Fatal(err) + } manifestDir := filepath.Join(root, "manifests") if err := os.MkdirAll(manifestDir, 0o755); err != nil { @@ -193,7 +238,11 @@ func makeSkillsSource(t *testing.T) string { SkillFile: "skills/evo-skill-creator/SKILL.md", InstallTarget: "core/custom/skills/evo-skill-creator", ContentHash: hash, - ModeSupport: []string{"copy", "link"}, + FileHashes: map[string]string{ + "SKILL.md": hash, + "agents/openai.yaml": agentHash, + }, + ModeSupport: []string{"copy", "link"}, }, }, } @@ -206,3 +255,25 @@ func makeSkillsSource(t *testing.T) string { } return root } + +func rewriteSkillsManifest(t *testing.T, sourceRoot string, mutate func(*skillsManifest)) { + t.Helper() + + manifestPath := filepath.Join(sourceRoot, "manifests", "evo-skills.manifest.json") + raw, err := os.ReadFile(manifestPath) + if err != nil { + t.Fatal(err) + } + var manifest skillsManifest + if err := json.Unmarshal(raw, &manifest); err != nil { + t.Fatal(err) + } + mutate(&manifest) + raw, err = json.MarshalIndent(manifest, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(manifestPath, append(raw, '\n'), 0o644); err != nil { + t.Fatal(err) + } +} From f60b68c8350cbbabdf020951faf8d7ab5ab4f2d4 Mon Sep 17 00:00:00 2001 From: middleDuckAi <269711613+middleDuckAi@users.noreply.github.com> Date: Fri, 5 Jun 2026 18:36:25 +0700 Subject: [PATCH 3/3] add(skills): record workflow autoload evidence --- internal/engine/install/skills.go | 227 ++++++++++++++++++++++++- internal/engine/install/skills_test.go | 126 ++++++++++++++ 2 files changed, 346 insertions(+), 7 deletions(-) diff --git a/internal/engine/install/skills.go b/internal/engine/install/skills.go index e9f1b10..cf148aa 100644 --- a/internal/engine/install/skills.go +++ b/internal/engine/install/skills.go @@ -21,6 +21,7 @@ const ( skillsManifestPath = "manifests/evo-skills.manifest.json" skillsStateSchema = "evo.skills.install-state.v1" skillsManifestVersion = "evo.skills.manifest.v1" + skillsWorkflowVersion = "evo.skills.workflow.v1" ) type skillsManifest struct { @@ -38,6 +39,9 @@ type skillsManifestEntry struct { InstallTarget string `json:"install_target"` ContentHash string `json:"content_hash"` FileHashes map[string]string `json:"file_hashes,omitempty"` + WorkflowID string `json:"workflow_id,omitempty"` + WorkflowFile string `json:"workflow_file,omitempty"` + WorkflowHash string `json:"workflow_hash,omitempty"` ModeSupport []string `json:"mode_support"` } @@ -56,13 +60,63 @@ type skillsInstallPlan struct { } type skillsInstalledItem struct { - Name string `json:"name"` - SourcePath string `json:"source_path"` - TargetPath string `json:"target_path"` - ContentHash string `json:"content_hash"` - FileHashes map[string]string `json:"file_hashes,omitempty"` - Mode string `json:"mode"` - Status string `json:"status"` + Name string `json:"name"` + SourcePath string `json:"source_path"` + TargetPath string `json:"target_path"` + ContentHash string `json:"content_hash"` + FileHashes map[string]string `json:"file_hashes,omitempty"` + Workflow *skillsWorkflowEvidence `json:"workflow,omitempty"` + Mode string `json:"mode"` + Status string `json:"status"` +} + +type skillsWorkflowDefinition struct { + SchemaVersion string `json:"schema_version"` + WorkflowID string `json:"workflow_id"` + Name string `json:"name"` + Version string `json:"version"` + Status string `json:"status"` + Autoload bool `json:"autoload"` + Autorun bool `json:"autorun"` + OwnerApprovalRequired bool `json:"owner_approval_required"` + PromotionAllowed bool `json:"promotion_allowed"` + NoWriteActions bool `json:"no_write_actions"` + Dependencies []string `json:"dependencies"` + Stages []skillsWorkflowStage `json:"stages"` +} + +type skillsWorkflowStage struct { + ID string `json:"id"` + Order int `json:"order"` + Label string `json:"label"` + Purpose string `json:"purpose,omitempty"` + WriteAction bool `json:"write_action"` + Status string `json:"status"` +} + +type skillsWorkflowEvidence struct { + WorkflowID string `json:"workflow_id"` + WorkflowVersion string `json:"workflow_version"` + WorkflowHash string `json:"workflow_hash"` + WorkflowFile string `json:"workflow_file"` + Status string `json:"status"` + Autoload bool `json:"autoload"` + Autorun bool `json:"autorun"` + OwnerApprovalRequired bool `json:"owner_approval_required"` + PromotionAllowed bool `json:"promotion_allowed"` + NoWriteActionsExecuted bool `json:"no_write_actions_executed"` + DryRunResult string `json:"dry_run_result"` + Dependencies []string `json:"dependencies"` + ResolvedOrder []string `json:"resolved_order"` + Stages []skillsWorkflowStageEvidence `json:"stages,omitempty"` +} + +type skillsWorkflowStageEvidence struct { + ID string `json:"id"` + Label string `json:"label"` + Order int `json:"order"` + Status string `json:"status"` + WriteAction bool `json:"write_action"` } type skillsInstallOperation struct { @@ -181,6 +235,29 @@ func (e *Engine) maybeRunSkillsInstall(ctx context.Context, emit func(domain.Eve Message: fmt.Sprintf("Planned EVO skills install: %s (%s mode).", strings.Join(plan.Selected, ", "), plan.Mode), }, }) + for _, item := range plan.InstalledSkills { + if item.Workflow == nil { + continue + } + _ = emit(domain.Event{ + Type: domain.EventLog, + StepID: skillsStepID, + Source: "skills", + Severity: domain.SeverityInfo, + Payload: domain.LogPayload{ + Message: fmt.Sprintf( + "Workflow autoload planned for %s: %s (%s).", + item.Name, + item.Workflow.WorkflowID, + strings.Join(item.Workflow.ResolvedOrder, " -> "), + ), + Fields: map[string]string{ + "autorun": "false", + "no_write_actions_executed": "true", + }, + }, + }) + } if !plan.DryRun { if err := applySkillsInstallPlan(plan); err != nil { @@ -327,6 +404,19 @@ func planSkillsInstall(opt Options, workDir string) (skillsInstallPlan, error) { if err != nil { return skillsInstallPlan{}, err } + workflowEvidence, err := resolveSkillWorkflow(sourceRoot, item) + if err != nil { + return skillsInstallPlan{}, err + } + if workflowEvidence != nil { + plan.Operations = append(plan.Operations, skillsInstallOperation{ + Kind: "autoload-workflow", + Source: filepath.ToSlash(item.WorkflowFile), + Target: workflowEvidence.WorkflowID, + Ownership: "managed", + Status: "planned", + }) + } ownership := "managed" if _, err := os.Lstat(targetDir); err == nil { @@ -344,6 +434,7 @@ func planSkillsInstall(opt Options, workDir string) (skillsInstallPlan, error) { TargetPath: filepath.ToSlash(item.InstallTarget), ContentHash: hash, FileHashes: fileHashes, + Workflow: workflowEvidence, Mode: mode, Status: "installed", }) @@ -373,6 +464,7 @@ func planSkillsInstall(opt Options, workDir string) (skillsInstallPlan, error) { TargetPath: filepath.ToSlash(item.InstallTarget), ContentHash: hash, FileHashes: fileHashes, + Workflow: workflowEvidence, Mode: mode, Status: "installed", }) @@ -561,6 +653,127 @@ func verifySkillFileHashes(sourceDir string, item skillsManifestEntry) (map[stri return out, nil } +func resolveSkillWorkflow(sourceRoot string, item skillsManifestEntry) (*skillsWorkflowEvidence, error) { + if strings.TrimSpace(item.WorkflowID) == "" && strings.TrimSpace(item.WorkflowFile) == "" && strings.TrimSpace(item.WorkflowHash) == "" { + return nil, nil + } + if strings.TrimSpace(item.WorkflowID) == "" || strings.TrimSpace(item.WorkflowFile) == "" || strings.TrimSpace(item.WorkflowHash) == "" { + return nil, fmt.Errorf("skill %q workflow_id, workflow_file, and workflow_hash must be declared together", item.Name) + } + cleanPath := filepath.Clean(filepath.FromSlash(item.WorkflowFile)) + if filepath.IsAbs(cleanPath) || cleanPath == ".." || strings.HasPrefix(cleanPath, ".."+string(filepath.Separator)) { + return nil, fmt.Errorf("skill %q declares unsafe workflow_file %q", item.Name, item.WorkflowFile) + } + workflowPath := filepath.Join(sourceRoot, cleanPath) + stat, err := os.Stat(workflowPath) + if err != nil || stat.IsDir() { + return nil, fmt.Errorf("skill %q workflow_file is not readable: %s", item.Name, filepath.ToSlash(cleanPath)) + } + actualHash, err := sha256File(workflowPath) + if err != nil { + return nil, fmt.Errorf("unable to hash skill %q workflow_file %q: %w", item.Name, filepath.ToSlash(cleanPath), err) + } + if expected := strings.TrimSpace(item.WorkflowHash); expected != "" && expected != actualHash { + return nil, fmt.Errorf("skill %q workflow hash mismatch: manifest %s, actual %s", item.Name, expected, actualHash) + } + raw, err := os.ReadFile(workflowPath) + if err != nil { + return nil, fmt.Errorf("unable to read skill %q workflow_file %q: %w", item.Name, filepath.ToSlash(cleanPath), err) + } + var workflow skillsWorkflowDefinition + if err := json.Unmarshal(raw, &workflow); err != nil { + return nil, fmt.Errorf("invalid skill %q workflow JSON: %w", item.Name, err) + } + return workflowEvidence(item, filepath.ToSlash(cleanPath), actualHash, workflow) +} + +func workflowEvidence(item skillsManifestEntry, workflowFile string, workflowHash string, workflow skillsWorkflowDefinition) (*skillsWorkflowEvidence, error) { + if strings.TrimSpace(workflow.SchemaVersion) != skillsWorkflowVersion { + return nil, fmt.Errorf("skill %q workflow has unsupported schema version %q", item.Name, workflow.SchemaVersion) + } + if workflow.WorkflowID != item.WorkflowID { + return nil, fmt.Errorf("skill %q workflow_id mismatch: manifest %q, workflow %q", item.Name, item.WorkflowID, workflow.WorkflowID) + } + if strings.TrimSpace(workflow.Version) == "" { + return nil, fmt.Errorf("skill %q workflow version is required", item.Name) + } + if !workflow.Autoload { + return nil, fmt.Errorf("skill %q workflow autoload must be true", item.Name) + } + if workflow.Autorun { + return nil, fmt.Errorf("skill %q workflow autorun must be false", item.Name) + } + if !workflow.OwnerApprovalRequired { + return nil, fmt.Errorf("skill %q workflow owner_approval_required must be true", item.Name) + } + if workflow.PromotionAllowed { + return nil, fmt.Errorf("skill %q workflow promotion_allowed must be false in CLI proof", item.Name) + } + if !workflow.NoWriteActions { + return nil, fmt.Errorf("skill %q workflow no_write_actions must be true", item.Name) + } + if len(workflow.Stages) == 0 { + return nil, fmt.Errorf("skill %q workflow stages are required", item.Name) + } + + seen := map[string]struct{}{} + lastOrder := 0 + resolvedOrder := make([]string, 0, len(workflow.Stages)) + stages := make([]skillsWorkflowStageEvidence, 0, len(workflow.Stages)) + for _, stage := range workflow.Stages { + if strings.TrimSpace(stage.ID) == "" { + return nil, fmt.Errorf("skill %q workflow stage id is required", item.Name) + } + if _, ok := seen[stage.ID]; ok { + return nil, fmt.Errorf("skill %q workflow stage %q is duplicated", item.Name, stage.ID) + } + seen[stage.ID] = struct{}{} + if stage.Order <= lastOrder { + return nil, fmt.Errorf("skill %q workflow stage %q order is not strictly increasing", item.Name, stage.ID) + } + lastOrder = stage.Order + if strings.TrimSpace(stage.Label) == "" { + return nil, fmt.Errorf("skill %q workflow stage %q label is required", item.Name, stage.ID) + } + if stage.WriteAction { + return nil, fmt.Errorf("skill %q workflow stage %q declares a write action; autoload proof forbids it", item.Name, stage.ID) + } + status := strings.TrimSpace(stage.Status) + if status == "" { + status = "visible" + } + resolvedOrder = append(resolvedOrder, stage.Label) + stages = append(stages, skillsWorkflowStageEvidence{ + ID: stage.ID, + Label: stage.Label, + Order: stage.Order, + Status: status, + WriteAction: false, + }) + } + + dependencies := workflow.Dependencies + if dependencies == nil { + dependencies = []string{} + } + return &skillsWorkflowEvidence{ + WorkflowID: workflow.WorkflowID, + WorkflowVersion: workflow.Version, + WorkflowHash: workflowHash, + WorkflowFile: workflowFile, + Status: "available", + Autoload: true, + Autorun: false, + OwnerApprovalRequired: true, + PromotionAllowed: false, + NoWriteActionsExecuted: true, + DryRunResult: "workflow_plan_only_no_actions", + Dependencies: dependencies, + ResolvedOrder: resolvedOrder, + Stages: stages, + }, nil +} + func isManagedSkillTarget(state skillsInstallState, name string, target string) bool { for _, item := range state.InstalledSkills { if item.Name == name || filepath.ToSlash(item.TargetPath) == filepath.ToSlash(target) { diff --git a/internal/engine/install/skills_test.go b/internal/engine/install/skills_test.go index 92ec63a..8969326 100644 --- a/internal/engine/install/skills_test.go +++ b/internal/engine/install/skills_test.go @@ -116,6 +116,78 @@ func TestApplySkillsInstallLinkCreatesSymlink(t *testing.T) { } } +func TestPlanSkillsInstallAutoloadsWorkflowWithoutAutorun(t *testing.T) { + t.Parallel() + + sourceRoot := makeSkillsSource(t) + addWorkflowToSkillsSource(t, sourceRoot, false) + projectRoot := t.TempDir() + + plan, err := planSkillsInstall(Options{ + Skills: []string{"evo-skill-creator"}, + SkillsSource: sourceRoot, + SkillsRef: "workflow-proof", + }, projectRoot) + if err != nil { + t.Fatalf("planSkillsInstall returned error: %v", err) + } + if len(plan.InstalledSkills) != 1 { + t.Fatalf("installed skills = %#v", plan.InstalledSkills) + } + workflow := plan.InstalledSkills[0].Workflow + if workflow == nil { + t.Fatal("expected workflow evidence") + } + if workflow.WorkflowID != "test-workflow.autoload.v1" { + t.Fatalf("workflow id = %q", workflow.WorkflowID) + } + if workflow.Autorun { + t.Fatal("workflow autorun should be false") + } + if !workflow.NoWriteActionsExecuted { + t.Fatal("workflow evidence must record no_write_actions_executed") + } + if strings.Join(workflow.ResolvedOrder, " -> ") != "Seed -> Gate -> Evidence" { + t.Fatalf("resolved order = %#v", workflow.ResolvedOrder) + } + if !hasSkillsOperation(plan.Operations, "autoload-workflow") { + t.Fatalf("expected autoload-workflow operation, got %#v", plan.Operations) + } + + if err := applySkillsInstallPlan(plan); err != nil { + t.Fatalf("applySkillsInstallPlan returned error: %v", err) + } + raw, err := os.ReadFile(filepath.Join(projectRoot, "core", "custom", "skills", ".evo-skills.lock.json")) + if err != nil { + t.Fatalf("expected lockfile: %v", err) + } + var state skillsInstallState + if err := json.Unmarshal(raw, &state); err != nil { + t.Fatalf("invalid lockfile JSON: %v", err) + } + if state.InstalledSkills[0].Workflow == nil { + t.Fatalf("lockfile missing workflow evidence: %s", raw) + } + if state.InstalledSkills[0].Workflow.DryRunResult != "workflow_plan_only_no_actions" { + t.Fatalf("dry-run result = %q", state.InstalledSkills[0].Workflow.DryRunResult) + } +} + +func TestPlanSkillsInstallWorkflowAutorunFails(t *testing.T) { + t.Parallel() + + sourceRoot := makeSkillsSource(t) + addWorkflowToSkillsSource(t, sourceRoot, true) + + _, err := planSkillsInstall(Options{ + Skills: []string{"evo-skill-creator"}, + SkillsSource: sourceRoot, + }, t.TempDir()) + if err == nil || !strings.Contains(err.Error(), "autorun must be false") { + t.Fatalf("expected autorun error, got %v", err) + } +} + func TestPlanSkillsInstallMissingSkillFails(t *testing.T) { t.Parallel() @@ -277,3 +349,57 @@ func rewriteSkillsManifest(t *testing.T, sourceRoot string, mutate func(*skillsM t.Fatal(err) } } + +func addWorkflowToSkillsSource(t *testing.T, sourceRoot string, autorun bool) { + t.Helper() + + workflowDir := filepath.Join(sourceRoot, "workflows") + if err := os.MkdirAll(workflowDir, 0o755); err != nil { + t.Fatal(err) + } + workflow := skillsWorkflowDefinition{ + SchemaVersion: skillsWorkflowVersion, + WorkflowID: "test-workflow.autoload.v1", + Name: "Test Workflow", + Version: "0.1.0", + Status: "p0-proof", + Autoload: true, + Autorun: autorun, + OwnerApprovalRequired: true, + PromotionAllowed: false, + NoWriteActions: true, + Dependencies: []string{}, + Stages: []skillsWorkflowStage{ + {ID: "seed", Label: "Seed", Order: 1, Status: "visible", WriteAction: false}, + {ID: "gate", Label: "Gate", Order: 2, Status: "visible", WriteAction: false}, + {ID: "evidence", Label: "Evidence", Order: 3, Status: "required", WriteAction: false}, + }, + } + raw, err := json.MarshalIndent(workflow, "", " ") + if err != nil { + t.Fatal(err) + } + workflowPath := filepath.Join(workflowDir, "test.workflow.json") + if err := os.WriteFile(workflowPath, append(raw, '\n'), 0o644); err != nil { + t.Fatal(err) + } + workflowHash, err := sha256File(workflowPath) + if err != nil { + t.Fatal(err) + } + + rewriteSkillsManifest(t, sourceRoot, func(manifest *skillsManifest) { + manifest.Skills[0].WorkflowID = "test-workflow.autoload.v1" + manifest.Skills[0].WorkflowFile = "workflows/test.workflow.json" + manifest.Skills[0].WorkflowHash = workflowHash + }) +} + +func hasSkillsOperation(ops []skillsInstallOperation, kind string) bool { + for _, op := range ops { + if op.Kind == kind { + return true + } + } + return false +}